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.
- package/CHANGELOG.md +39 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +25 -2
- package/extensions/shared.ts +24 -0
- package/extensions/tools/dependencies.ts +0 -120
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +427 -44
- package/helpers/scan_project.py +15 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +11 -6
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +63 -55
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/build/traceback.ts +55 -0
- package/src/dependencies/aliases.ts +101 -0
- package/src/dependencies/plan.ts +60 -112
- 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 +126 -53
- 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 +61 -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,16 +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';
|
|
22
|
+
import { buildCompletionEvidence } from '../../src/validation/evidence.ts';
|
|
23
|
+
import { checkTdd } from '../../src/validation/tdd.ts';
|
|
14
24
|
import { join } from 'node:path';
|
|
15
25
|
import { isFile } from '../../src/project/root.ts';
|
|
16
26
|
import { runScanProject } from '../../src/project/scanner.ts';
|
|
17
|
-
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
27
|
+
import { hasDirectory, messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
18
28
|
|
|
19
29
|
function stepFrom(run: { code: number | null; timedOut: boolean }): ValidationStep {
|
|
20
30
|
return { executed: true, ok: run.code === 0 && !run.timedOut, exitCode: run.code };
|
|
21
31
|
}
|
|
22
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
|
+
|
|
23
42
|
export function registerValidationTools(pi: Pi): void {
|
|
24
43
|
pi.registerTool({
|
|
25
44
|
name: 'py_sync',
|
|
@@ -29,11 +48,19 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
29
48
|
promptSnippet: 'Preview or run the uv environment sync',
|
|
30
49
|
promptGuidelines: [
|
|
31
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.',
|
|
32
52
|
],
|
|
33
53
|
parameters: Type.Object({
|
|
34
54
|
mode: Type.Optional(
|
|
35
55
|
Type.Union([Type.Literal('check'), Type.Literal('sync')], {
|
|
36
|
-
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.',
|
|
37
64
|
}),
|
|
38
65
|
),
|
|
39
66
|
execute: Type.Optional(Type.Boolean()),
|
|
@@ -45,7 +72,8 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
45
72
|
try {
|
|
46
73
|
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
47
74
|
const mode = params.mode ?? 'check';
|
|
48
|
-
const
|
|
75
|
+
const extras = params.extras ?? 'all';
|
|
76
|
+
const command = mode === 'check' ? uvLockCheck(ctx.cwd) : uvSyncFrozen(ctx.cwd, { extras });
|
|
49
77
|
const lockPresent = await isFile(join(root, 'uv.lock'));
|
|
50
78
|
|
|
51
79
|
if (!params.execute) {
|
|
@@ -53,7 +81,7 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
53
81
|
result(ctx.cwd, started, {
|
|
54
82
|
ok: true,
|
|
55
83
|
summary: `${mode} command preview generated; nothing was executed.`,
|
|
56
|
-
data: { executed: false, mode, command, lockPresent },
|
|
84
|
+
data: { executed: false, mode, extras, command, lockPresent },
|
|
57
85
|
evidence: [{ kind: 'command_preview', ...command }],
|
|
58
86
|
warnings: lockPresent
|
|
59
87
|
? []
|
|
@@ -88,51 +116,148 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
88
116
|
});
|
|
89
117
|
const output = `${run.stdout}\n${run.stderr}`;
|
|
90
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');
|
|
91
177
|
|
|
92
178
|
return text(
|
|
93
179
|
result(ctx.cwd, started, {
|
|
94
|
-
ok: run.code === 0,
|
|
180
|
+
ok: run.code === 0 && !pytestMissing,
|
|
95
181
|
summary: run.timedOut
|
|
96
182
|
? 'uv exceeded the time limit and was terminated.'
|
|
97
|
-
: run.code
|
|
98
|
-
?
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.',
|
|
102
190
|
data: {
|
|
103
191
|
executed: true,
|
|
104
192
|
mode,
|
|
193
|
+
extras,
|
|
105
194
|
exitCode: run.code,
|
|
106
195
|
truncated: run.truncated,
|
|
107
196
|
timedOut: run.timedOut,
|
|
197
|
+
inventory,
|
|
198
|
+
removed: inventory.uninstalled,
|
|
199
|
+
toolchain,
|
|
108
200
|
stdoutTail: run.stdout.slice(-4000),
|
|
109
201
|
stderrTail: run.stderr.slice(-4000),
|
|
110
202
|
diagnosis,
|
|
111
203
|
},
|
|
112
|
-
evidence: [
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
],
|
|
122
235
|
errors:
|
|
123
|
-
run.code === 0
|
|
236
|
+
run.code === 0 && !pytestMissing
|
|
124
237
|
? []
|
|
125
238
|
: [
|
|
126
239
|
{
|
|
127
|
-
code:
|
|
128
|
-
message:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.'),
|
|
132
246
|
severity: 'error' as const,
|
|
133
247
|
},
|
|
134
248
|
],
|
|
135
|
-
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 ?? []),
|
|
136
261
|
commands: [command],
|
|
137
262
|
truncated: run.truncated,
|
|
138
263
|
projectRoot: root,
|
|
@@ -156,6 +281,12 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
156
281
|
],
|
|
157
282
|
parameters: Type.Object({
|
|
158
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
|
+
),
|
|
159
290
|
execute: Type.Optional(Type.Boolean()),
|
|
160
291
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
|
|
161
292
|
path: Type.Optional(Type.String()),
|
|
@@ -169,14 +300,44 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
169
300
|
const sync = uvSyncFrozen(ctx.cwd);
|
|
170
301
|
const test = pytestCommand(ctx.cwd, { targets: params.targets });
|
|
171
302
|
const timeoutMs = (params.timeoutSeconds ?? 1800) * 1000;
|
|
172
|
-
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];
|
|
173
322
|
|
|
174
323
|
if (!params.execute) {
|
|
175
324
|
return text(
|
|
176
325
|
result(ctx.cwd, started, {
|
|
177
326
|
ok: true,
|
|
178
|
-
summary: `Validation sequence preview generated (${
|
|
179
|
-
|
|
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
|
+
},
|
|
180
341
|
evidence: [
|
|
181
342
|
{
|
|
182
343
|
kind: 'validation_preview',
|
|
@@ -195,7 +356,8 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
195
356
|
errors: [],
|
|
196
357
|
suggestions: [
|
|
197
358
|
{
|
|
198
|
-
message:
|
|
359
|
+
message:
|
|
360
|
+
'Set execute=true to run the sequence. This creates or refreshes .venv, including every declared extra.',
|
|
199
361
|
confidence: 'high' as const,
|
|
200
362
|
},
|
|
201
363
|
],
|
|
@@ -219,17 +381,35 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
219
381
|
timeoutMs,
|
|
220
382
|
maxBytes: 256 * 1024,
|
|
221
383
|
});
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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;
|
|
231
409
|
|
|
232
410
|
const report = testRun ? parsePytestOutput(testRun.stdout, testRun.stderr) : undefined;
|
|
411
|
+
const testPassed = Boolean(report && testRun?.code === 0 && !report.incomplete);
|
|
412
|
+
|
|
233
413
|
const scan = await runScanProject(ctx.cwd, { root, mode: 'manifest' }, signal);
|
|
234
414
|
const installed = await readInstalledDistributions(join(root, '.venv'));
|
|
235
415
|
const conformance = compareInstalledConformance({
|
|
@@ -242,19 +422,63 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
242
422
|
|
|
243
423
|
const lockStep: ValidationStep = lockRun
|
|
244
424
|
? stepFrom(lockRun)
|
|
245
|
-
:
|
|
246
|
-
|
|
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
|
+
};
|
|
247
436
|
const testStep: ValidationStep = testRun
|
|
248
437
|
? {
|
|
249
438
|
...stepFrom(testRun),
|
|
250
439
|
failures: report ? report.counts.failed + report.counts.errors : undefined,
|
|
251
440
|
}
|
|
252
|
-
:
|
|
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
|
+
}
|
|
253
476
|
|
|
254
477
|
const summary = summarizeValidation({
|
|
255
478
|
lock: lockStep,
|
|
256
479
|
sync: syncStep,
|
|
257
480
|
test: testStep,
|
|
481
|
+
quality: qualitySteps,
|
|
258
482
|
conformance: conformance.verdict,
|
|
259
483
|
stale: staleness.stale,
|
|
260
484
|
});
|
|
@@ -272,12 +496,17 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
272
496
|
executed: true,
|
|
273
497
|
checks: summary.checks,
|
|
274
498
|
lock: { ...lockStep, present: lockPresent },
|
|
275
|
-
sync:
|
|
499
|
+
sync: {
|
|
500
|
+
...syncStep,
|
|
501
|
+
uninstalled: syncInventory.uninstalled,
|
|
502
|
+
toolchainMissing: toolchain.missing,
|
|
503
|
+
},
|
|
276
504
|
test: {
|
|
277
505
|
...testStep,
|
|
278
506
|
counts: report?.counts,
|
|
279
507
|
failures: report?.failures.slice(0, 20),
|
|
280
508
|
},
|
|
509
|
+
quality: qualitySteps,
|
|
281
510
|
conformance,
|
|
282
511
|
staleArtifacts: staleness,
|
|
283
512
|
firstFailure: diagnosis,
|
|
@@ -288,14 +517,32 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
288
517
|
checks: summary.checks,
|
|
289
518
|
lockExitCode: lockStep.exitCode ?? null,
|
|
290
519
|
syncExitCode: syncStep.exitCode ?? null,
|
|
520
|
+
syncUninstalled: syncInventory.uninstalled,
|
|
291
521
|
testExitCode: testStep.exitCode ?? null,
|
|
292
522
|
testCounts: report?.counts ?? null,
|
|
523
|
+
quality: qualitySteps.map((step) => ({ name: step.name, ok: step.ok })),
|
|
293
524
|
conformanceVerdict: conformance.verdict,
|
|
294
525
|
stale: staleness.stale,
|
|
295
526
|
},
|
|
296
527
|
],
|
|
297
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
|
+
: []),
|
|
298
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
|
+
})),
|
|
299
546
|
...staleness.artifacts.map((artifact) => ({
|
|
300
547
|
code: artifact.code,
|
|
301
548
|
message: artifact.message,
|
|
@@ -329,6 +576,16 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
329
576
|
'Fix the failing step before reporting completion; a partial run is not evidence.',
|
|
330
577
|
confidence: 'high' as const,
|
|
331
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
|
+
: []),
|
|
332
589
|
...(diagnosis?.suggestions ?? []),
|
|
333
590
|
],
|
|
334
591
|
commands,
|
|
@@ -341,4 +598,130 @@ export function registerValidationTools(pi: Pi): void {
|
|
|
341
598
|
}
|
|
342
599
|
},
|
|
343
600
|
});
|
|
601
|
+
|
|
602
|
+
pi.registerTool({
|
|
603
|
+
name: 'py_tdd_checkpoint',
|
|
604
|
+
label: 'Python TDD Checkpoint',
|
|
605
|
+
description:
|
|
606
|
+
'Check whether production Python changes have related test changes before implementation is considered complete. Read-only.',
|
|
607
|
+
promptSnippet: 'Check the Python TDD checkpoint for changed files',
|
|
608
|
+
promptGuidelines: [
|
|
609
|
+
'Use py_tdd_checkpoint before reporting Python implementation work as complete.',
|
|
610
|
+
],
|
|
611
|
+
parameters: Type.Object({
|
|
612
|
+
changedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
613
|
+
testChangedPaths: Type.Optional(Type.Array(Type.String(), { maxItems: 500 })),
|
|
614
|
+
}),
|
|
615
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
616
|
+
const started = Date.now();
|
|
617
|
+
let changedPaths = params.changedPaths ?? [];
|
|
618
|
+
let source = 'argument';
|
|
619
|
+
if (!changedPaths.length) {
|
|
620
|
+
const diff = await runCommand('git', ['diff', '--name-only', 'HEAD'], {
|
|
621
|
+
cwd: ctx.cwd,
|
|
622
|
+
signal,
|
|
623
|
+
timeoutMs: 10000,
|
|
624
|
+
maxBytes: 100_000,
|
|
625
|
+
});
|
|
626
|
+
changedPaths = diff.stdout
|
|
627
|
+
.split(/\r?\n/)
|
|
628
|
+
.map((line) => line.trim())
|
|
629
|
+
.filter(Boolean);
|
|
630
|
+
source = 'git diff';
|
|
631
|
+
}
|
|
632
|
+
const checkpoint = checkTdd(changedPaths, params.testChangedPaths ?? []);
|
|
633
|
+
return text(
|
|
634
|
+
result(ctx.cwd, started, {
|
|
635
|
+
ok: checkpoint.ok,
|
|
636
|
+
summary: checkpoint.ok
|
|
637
|
+
? `TDD checkpoint passed across ${changedPaths.length} changed path(s) from ${source}.`
|
|
638
|
+
: 'TDD checkpoint found production changes without a related test change.',
|
|
639
|
+
data: { ...checkpoint, changedPaths, source },
|
|
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
|
+
],
|
|
650
|
+
warnings: checkpoint.reasons.map((message) => ({
|
|
651
|
+
code: 'TDD_CHECKPOINT',
|
|
652
|
+
message,
|
|
653
|
+
severity: 'warning' as const,
|
|
654
|
+
})),
|
|
655
|
+
errors: [],
|
|
656
|
+
suggestions: checkpoint.ok
|
|
657
|
+
? []
|
|
658
|
+
: [
|
|
659
|
+
{
|
|
660
|
+
message:
|
|
661
|
+
'Add the smallest focused test for the changed behaviour, or explain why the change needs no test.',
|
|
662
|
+
confidence: 'high' as const,
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
pi.registerTool({
|
|
671
|
+
name: 'py_completion_evidence',
|
|
672
|
+
label: 'Python Completion Evidence',
|
|
673
|
+
description:
|
|
674
|
+
'Build a conservative completion report from environment sync and test execution results. Read-only.',
|
|
675
|
+
promptSnippet: 'Create evidence for a Python completion report',
|
|
676
|
+
promptGuidelines: [
|
|
677
|
+
'Use py_completion_evidence before claiming Python work is complete; a partial run is not evidence.',
|
|
678
|
+
],
|
|
679
|
+
parameters: Type.Object({
|
|
680
|
+
syncExecuted: Type.Boolean({
|
|
681
|
+
description: 'Whether uv lock --check / uv sync actually ran.',
|
|
682
|
+
}),
|
|
683
|
+
syncOk: Type.Boolean(),
|
|
684
|
+
testExecuted: Type.Boolean({ description: 'Whether pytest actually ran.' }),
|
|
685
|
+
testOk: Type.Boolean(),
|
|
686
|
+
stale: Type.Boolean({ description: 'Whether stale artifacts were detected.' }),
|
|
687
|
+
changedPaths: Type.Array(Type.String(), { maxItems: 500 }),
|
|
688
|
+
}),
|
|
689
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
690
|
+
const started = Date.now();
|
|
691
|
+
const evidence = buildCompletionEvidence(params);
|
|
692
|
+
return text(
|
|
693
|
+
result(ctx.cwd, started, {
|
|
694
|
+
ok: evidence.ok,
|
|
695
|
+
summary: evidence.ok
|
|
696
|
+
? 'Completion evidence is sufficient for the supplied checks.'
|
|
697
|
+
: 'Completion evidence is incomplete or contains failing checks.',
|
|
698
|
+
data: evidence,
|
|
699
|
+
evidence: evidence.blockers.map((message) => ({ kind: 'completion_blocker', message })),
|
|
700
|
+
warnings: evidence.blockers.map((message) => ({
|
|
701
|
+
code: 'INCOMPLETE_EVIDENCE',
|
|
702
|
+
message,
|
|
703
|
+
severity: 'warning' as const,
|
|
704
|
+
})),
|
|
705
|
+
errors: evidence.ok
|
|
706
|
+
? []
|
|
707
|
+
: [
|
|
708
|
+
{
|
|
709
|
+
code: 'COMPLETION_NOT_PROVEN',
|
|
710
|
+
message: 'The supplied evidence does not prove completion.',
|
|
711
|
+
severity: 'error' as const,
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
suggestions: evidence.ok
|
|
715
|
+
? []
|
|
716
|
+
: [
|
|
717
|
+
{
|
|
718
|
+
message:
|
|
719
|
+
'Run py_validation_bundle and address every blocker before reporting completion.',
|
|
720
|
+
confidence: 'high' as const,
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
},
|
|
726
|
+
});
|
|
344
727
|
}
|
package/helpers/scan_project.py
CHANGED
|
@@ -35,7 +35,9 @@ 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
|
+
SCANNER_VERSION = 2
|
|
39
41
|
|
|
40
42
|
KNOWN_SECTIONS = ("environment", "manifest", "imports")
|
|
41
43
|
EXIT_OK = 0
|
|
@@ -510,9 +512,12 @@ def _is_type_checking_test(test) -> bool:
|
|
|
510
512
|
|
|
511
513
|
|
|
512
514
|
class ImportCollector(ast.NodeVisitor):
|
|
515
|
+
"""Collect top-level import names and the full dotted modules they reference."""
|
|
516
|
+
|
|
513
517
|
def __init__(self) -> None:
|
|
514
518
|
self.all: set[str] = set()
|
|
515
519
|
self.type_checking: set[str] = set()
|
|
520
|
+
self.modules: set[str] = set()
|
|
516
521
|
self._guard_depth = 0
|
|
517
522
|
|
|
518
523
|
def _record(self, name: str) -> None:
|
|
@@ -536,12 +541,20 @@ class ImportCollector(ast.NodeVisitor):
|
|
|
536
541
|
def visit_Import(self, node: ast.Import) -> None:
|
|
537
542
|
for alias in node.names:
|
|
538
543
|
self._record(alias.name.split(".")[0])
|
|
544
|
+
self.modules.add(alias.name)
|
|
539
545
|
|
|
540
546
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
541
547
|
if node.level: # relative import -> always local
|
|
548
|
+
for alias in node.names:
|
|
549
|
+
self.modules.add(alias.name)
|
|
542
550
|
return
|
|
543
551
|
if node.module:
|
|
544
552
|
self._record(node.module.split(".")[0])
|
|
553
|
+
self.modules.add(node.module)
|
|
554
|
+
# `from pkg.db import database` names a submodule, not the package,
|
|
555
|
+
# so both spellings are recorded and either can match a change.
|
|
556
|
+
for alias in node.names:
|
|
557
|
+
self.modules.add(f"{node.module}.{alias.name}")
|
|
545
558
|
|
|
546
559
|
|
|
547
560
|
def scan_imports(root: Path, max_files: int) -> dict:
|
|
@@ -589,6 +602,7 @@ def scan_imports(root: Path, max_files: int) -> dict:
|
|
|
589
602
|
{
|
|
590
603
|
"path": relative,
|
|
591
604
|
"imports": sorted(names),
|
|
605
|
+
"importModules": sorted(collector.modules),
|
|
592
606
|
"typeCheckingImports": sorted(collector.type_checking),
|
|
593
607
|
}
|
|
594
608
|
)
|