pi-python-helper 0.1.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 +60 -0
- package/CONTRIBUTING.md +77 -0
- package/LICENSE +17 -0
- package/README.md +172 -0
- package/SECURITY.md +24 -0
- package/docs/compatibility.md +64 -0
- package/docs/tools.md +518 -0
- package/extensions/index.ts +28 -0
- package/extensions/shared.ts +48 -0
- package/extensions/tools/dependencies.ts +203 -0
- package/extensions/tools/environment.ts +171 -0
- package/extensions/tools/testing.ts +350 -0
- package/extensions/tools/validation.ts +344 -0
- package/helpers/scan_project.py +777 -0
- package/package.json +73 -0
- package/skills/python-development/SKILL.md +45 -0
- package/src/build/commands.ts +65 -0
- package/src/build/discover.ts +98 -0
- package/src/build/failure.ts +452 -0
- package/src/build/pytest.ts +118 -0
- package/src/build/selection.ts +138 -0
- package/src/build/staleness.ts +117 -0
- package/src/core/result.ts +102 -0
- package/src/core/runner.ts +96 -0
- package/src/core/safety.ts +181 -0
- package/src/core/version.ts +20 -0
- package/src/dependencies/plan.ts +398 -0
- package/src/environment/discovery.ts +166 -0
- package/src/environment/tools.ts +141 -0
- package/src/project/conformance.ts +343 -0
- package/src/project/inspect.ts +351 -0
- package/src/project/installed.ts +225 -0
- package/src/project/paths.ts +74 -0
- package/src/project/root.ts +72 -0
- package/src/project/scanner.ts +243 -0
- package/src/validation/bundle.ts +65 -0
- package/src/validation/evidence.ts +33 -0
- package/src/validation/tdd.ts +62 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { failure, result } from '../../src/core/result.ts';
|
|
3
|
+
import { runCommand } from '../../src/core/runner.ts';
|
|
4
|
+
import { pytestCommand } from '../../src/build/commands.ts';
|
|
5
|
+
import { changedPaths, listTestFiles } from '../../src/build/discover.ts';
|
|
6
|
+
import { diagnoseFailure, refineWithDeclarations } from '../../src/build/failure.ts';
|
|
7
|
+
import { parsePytestOutput } from '../../src/build/pytest.ts';
|
|
8
|
+
import { selectTests } from '../../src/build/selection.ts';
|
|
9
|
+
import { buildDeclaredIndex } from '../../src/dependencies/plan.ts';
|
|
10
|
+
import { runScanProject } from '../../src/project/scanner.ts';
|
|
11
|
+
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
12
|
+
|
|
13
|
+
/** Cross-check a diagnosis against what the project actually declares. */
|
|
14
|
+
async function refine(
|
|
15
|
+
diagnosis: ReturnType<typeof diagnoseFailure>,
|
|
16
|
+
cwd: string,
|
|
17
|
+
root: string | undefined,
|
|
18
|
+
signal: AbortSignal | undefined,
|
|
19
|
+
): Promise<ReturnType<typeof diagnoseFailure>> {
|
|
20
|
+
if (!diagnosis.missingModule || !root) return diagnosis;
|
|
21
|
+
const scan = await runScanProject(cwd, { root, mode: 'all', maxFiles: 2000 }, signal);
|
|
22
|
+
if (!scan.ok || !scan.payload) return diagnosis;
|
|
23
|
+
const declared = new Set(
|
|
24
|
+
buildDeclaredIndex(
|
|
25
|
+
scan.payload.manifest ?? {
|
|
26
|
+
dependencies: [],
|
|
27
|
+
optionalDependencies: {},
|
|
28
|
+
dependencyGroups: {},
|
|
29
|
+
},
|
|
30
|
+
).keys(),
|
|
31
|
+
);
|
|
32
|
+
const localModules = new Set(scan.payload.imports?.localModules ?? []);
|
|
33
|
+
return refineWithDeclarations(diagnosis, { declared, localModules });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerTestingTools(pi: Pi): void {
|
|
37
|
+
pi.registerTool({
|
|
38
|
+
name: 'py_test_select',
|
|
39
|
+
label: 'Python Test Select',
|
|
40
|
+
description:
|
|
41
|
+
'Select focused pytest targets from changed files using pytest naming conventions, without running tests. Read-only.',
|
|
42
|
+
promptSnippet: 'Select focused Python tests from changed files',
|
|
43
|
+
promptGuidelines: [
|
|
44
|
+
'Use py_test_select after changing Python source to choose a focused pytest target instead of running the whole suite.',
|
|
45
|
+
],
|
|
46
|
+
parameters: Type.Object({
|
|
47
|
+
changedPaths: Type.Optional(
|
|
48
|
+
Type.Array(Type.String(), {
|
|
49
|
+
description: 'Changed paths; defaults to git diff plus untracked files.',
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
testFiles: Type.Optional(
|
|
53
|
+
Type.Array(Type.String(), { description: 'Known test files; defaults to a project scan.' }),
|
|
54
|
+
),
|
|
55
|
+
path: Type.Optional(
|
|
56
|
+
Type.String({ description: 'Project directory to scan for test files.' }),
|
|
57
|
+
),
|
|
58
|
+
}),
|
|
59
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
try {
|
|
62
|
+
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
63
|
+
let changed = params.changedPaths ?? [];
|
|
64
|
+
let changedSource = 'argument';
|
|
65
|
+
if (!changed.length) {
|
|
66
|
+
const discovered = await changedPaths(ctx.cwd, signal);
|
|
67
|
+
changed = discovered.paths;
|
|
68
|
+
changedSource = discovered.source === 'git' ? 'git' : (discovered.error ?? 'none');
|
|
69
|
+
}
|
|
70
|
+
const testFiles = params.testFiles ?? (await listTestFiles(root));
|
|
71
|
+
const selection = selectTests(changed, testFiles);
|
|
72
|
+
const command = pytestCommand(ctx.cwd, {
|
|
73
|
+
targets: selection.fellBackToAll ? [] : selection.selected.map((entry) => entry.path),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return text(
|
|
77
|
+
result(ctx.cwd, started, {
|
|
78
|
+
ok: selection.selected.length > 0 || testFiles.length === 0,
|
|
79
|
+
summary:
|
|
80
|
+
`${selection.selected.length} test file(s) selected from ${testFiles.length} known test file(s) ` +
|
|
81
|
+
`for ${selection.changedSourceFiles.length} changed source file(s) (${changedSource}).` +
|
|
82
|
+
(selection.fellBackToAll
|
|
83
|
+
? ' No match was found, so the full suite is in scope.'
|
|
84
|
+
: ''),
|
|
85
|
+
data: { ...selection, pytestTargets: selection.selected.map((entry) => entry.path) },
|
|
86
|
+
evidence: [
|
|
87
|
+
{
|
|
88
|
+
kind: 'test_selection',
|
|
89
|
+
changedSourceFiles: selection.changedSourceFiles,
|
|
90
|
+
changedTestFiles: selection.changedTestFiles,
|
|
91
|
+
selected: selection.selected,
|
|
92
|
+
fellBackToAll: selection.fellBackToAll,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
warnings:
|
|
96
|
+
testFiles.length === 0
|
|
97
|
+
? [
|
|
98
|
+
{
|
|
99
|
+
code: 'NO_TEST_FILES',
|
|
100
|
+
message:
|
|
101
|
+
'No pytest test files were found. Create tests/test_<module>.py to enable focused selection.',
|
|
102
|
+
severity: 'warning' as const,
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
: [],
|
|
106
|
+
errors: [],
|
|
107
|
+
suggestions: selection.selected.length
|
|
108
|
+
? [
|
|
109
|
+
{
|
|
110
|
+
message: `Run the selected targets with py_test, or use py_test with lastFailed=true to rerun only previous failures.`,
|
|
111
|
+
confidence: 'high' as const,
|
|
112
|
+
},
|
|
113
|
+
]
|
|
114
|
+
: [],
|
|
115
|
+
commands: [command],
|
|
116
|
+
projectRoot: root,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.registerTool({
|
|
126
|
+
name: 'py_test',
|
|
127
|
+
label: 'Python Test',
|
|
128
|
+
description:
|
|
129
|
+
'Preview or run pytest through uv run --frozen and summarise failures by test, file, and first project frame. Does not modify sources.',
|
|
130
|
+
promptSnippet: 'Preview or run Python tests and summarise failures',
|
|
131
|
+
promptGuidelines: [
|
|
132
|
+
'Use py_test with execute=false first; a preview is never a passing test run.',
|
|
133
|
+
'Use py_test after changing Python sources; it does not rebuild anything, so run py_sync first when dependencies changed.',
|
|
134
|
+
],
|
|
135
|
+
parameters: Type.Object({
|
|
136
|
+
targets: Type.Optional(Type.Array(Type.String())),
|
|
137
|
+
lastFailed: Type.Optional(
|
|
138
|
+
Type.Boolean({ description: 'Rerun only tests that failed last time (--lf).' }),
|
|
139
|
+
),
|
|
140
|
+
keyword: Type.Optional(Type.String({ description: 'pytest -k expression.' })),
|
|
141
|
+
maxFail: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
|
|
142
|
+
execute: Type.Optional(Type.Boolean()),
|
|
143
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
|
|
144
|
+
path: Type.Optional(Type.String()),
|
|
145
|
+
}),
|
|
146
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
147
|
+
const started = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
150
|
+
const command = pytestCommand(ctx.cwd, {
|
|
151
|
+
targets: params.targets,
|
|
152
|
+
lastFailed: params.lastFailed,
|
|
153
|
+
keyword: params.keyword,
|
|
154
|
+
maxFail: params.maxFail,
|
|
155
|
+
});
|
|
156
|
+
if (!params.execute) {
|
|
157
|
+
return text(
|
|
158
|
+
result(ctx.cwd, started, {
|
|
159
|
+
ok: true,
|
|
160
|
+
summary: 'pytest command preview generated; no test was executed.',
|
|
161
|
+
data: { executed: false, command },
|
|
162
|
+
evidence: [{ kind: 'command_preview', ...command }],
|
|
163
|
+
warnings: [],
|
|
164
|
+
errors: [],
|
|
165
|
+
suggestions: [
|
|
166
|
+
{
|
|
167
|
+
message: 'Set execute=true to run the previewed pytest command.',
|
|
168
|
+
confidence: 'high' as const,
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
commands: [command],
|
|
172
|
+
projectRoot: root,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const run = await runCommand(command.executable, command.args, {
|
|
178
|
+
cwd: ctx.cwd,
|
|
179
|
+
signal,
|
|
180
|
+
timeoutMs: (params.timeoutSeconds ?? 900) * 1000,
|
|
181
|
+
maxBytes: 512 * 1024,
|
|
182
|
+
});
|
|
183
|
+
const report = parsePytestOutput(run.stdout, run.stderr);
|
|
184
|
+
const firstFailure = report.failures[0];
|
|
185
|
+
const diagnosis = firstFailure
|
|
186
|
+
? await refine(diagnoseFailure(`${run.stdout}\n${run.stderr}`), ctx.cwd, root, signal)
|
|
187
|
+
: undefined;
|
|
188
|
+
|
|
189
|
+
const failures = report.failures.slice(0, 20);
|
|
190
|
+
const timedOut = run.timedOut;
|
|
191
|
+
const failedCount = report.counts.failed + report.counts.errors;
|
|
192
|
+
const ok = run.code === 0 && !timedOut && !report.incomplete;
|
|
193
|
+
const errors: { code: string; message: string; severity: 'error' }[] = [];
|
|
194
|
+
if (timedOut) {
|
|
195
|
+
errors.push({
|
|
196
|
+
code: 'TEST_TIMEOUT',
|
|
197
|
+
message: 'pytest was terminated after exceeding the time limit.',
|
|
198
|
+
severity: 'error',
|
|
199
|
+
});
|
|
200
|
+
} else if (failedCount > 0) {
|
|
201
|
+
errors.push({
|
|
202
|
+
code: 'TESTS_FAILED',
|
|
203
|
+
message: `${report.counts.failed} test(s) failed and ${report.counts.errors} error(s) were reported.`,
|
|
204
|
+
severity: 'error',
|
|
205
|
+
});
|
|
206
|
+
} else if (report.incomplete) {
|
|
207
|
+
errors.push({
|
|
208
|
+
code: 'INCOMPLETE_TEST_OUTPUT',
|
|
209
|
+
message: 'pytest produced no summary, so no test result can be trusted.',
|
|
210
|
+
severity: 'error',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return text(
|
|
215
|
+
result(ctx.cwd, started, {
|
|
216
|
+
ok,
|
|
217
|
+
summary: timedOut
|
|
218
|
+
? 'pytest exceeded the time limit and was terminated.'
|
|
219
|
+
: report.incomplete
|
|
220
|
+
? 'pytest did not reach a summary; inspect the captured output before trusting any result.'
|
|
221
|
+
: `${report.counts.passed} passed, ${report.counts.failed} failed, ${report.counts.errors} error(s), ${report.counts.skipped} skipped.`,
|
|
222
|
+
data: {
|
|
223
|
+
executed: true,
|
|
224
|
+
exitCode: run.code,
|
|
225
|
+
truncated: run.truncated,
|
|
226
|
+
timedOut,
|
|
227
|
+
counts: report.counts,
|
|
228
|
+
summaryLine: report.summaryLine,
|
|
229
|
+
noTestsRan: report.noTestsRan,
|
|
230
|
+
failures,
|
|
231
|
+
failureCount: report.failures.length,
|
|
232
|
+
firstFailure: diagnosis,
|
|
233
|
+
},
|
|
234
|
+
evidence: [
|
|
235
|
+
{
|
|
236
|
+
kind: 'pytest_run',
|
|
237
|
+
exitCode: run.code,
|
|
238
|
+
counts: report.counts,
|
|
239
|
+
failures: failures.map((failure) => failure.test),
|
|
240
|
+
executed: true,
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
warnings: [
|
|
244
|
+
...(report.counts.warnings
|
|
245
|
+
? [
|
|
246
|
+
{
|
|
247
|
+
code: 'PYTEST_WARNINGS',
|
|
248
|
+
message: `pytest reported ${report.counts.warnings} warning(s); inspect them before changing test code.`,
|
|
249
|
+
severity: 'warning' as const,
|
|
250
|
+
},
|
|
251
|
+
]
|
|
252
|
+
: []),
|
|
253
|
+
...(report.incomplete
|
|
254
|
+
? [
|
|
255
|
+
{
|
|
256
|
+
code: 'INCOMPLETE_TEST_OUTPUT',
|
|
257
|
+
message:
|
|
258
|
+
'No pytest summary was found in the output; the run may have crashed during collection.',
|
|
259
|
+
severity: 'warning' as const,
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
: []),
|
|
263
|
+
],
|
|
264
|
+
errors,
|
|
265
|
+
suggestions: diagnosis?.suggestions ?? [],
|
|
266
|
+
commands: [command],
|
|
267
|
+
truncated: run.truncated,
|
|
268
|
+
projectRoot: root,
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
pi.registerTool({
|
|
278
|
+
name: 'py_failure_diagnose',
|
|
279
|
+
label: 'Python Failure Diagnose',
|
|
280
|
+
description:
|
|
281
|
+
'Classify the first actionable cause in bounded Python, pytest, or uv output and point at the first non-library traceback frame. Read-only.',
|
|
282
|
+
promptSnippet: 'Diagnose the first actionable Python failure',
|
|
283
|
+
promptGuidelines: [
|
|
284
|
+
'Use py_failure_diagnose on bounded command output instead of reading a full traceback in context; traceback frames inside site-packages are never the cause.',
|
|
285
|
+
],
|
|
286
|
+
parameters: Type.Object({
|
|
287
|
+
output: Type.String({ description: 'Bounded stdout/stderr from the failing command.' }),
|
|
288
|
+
path: Type.Optional(
|
|
289
|
+
Type.String({ description: 'Project directory used to classify the missing module.' }),
|
|
290
|
+
),
|
|
291
|
+
}),
|
|
292
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
293
|
+
const started = Date.now();
|
|
294
|
+
try {
|
|
295
|
+
const root = await resolveProjectRoot(ctx.cwd, params.path);
|
|
296
|
+
const base = diagnoseFailure(params.output);
|
|
297
|
+
const diagnosis = await refine(base, ctx.cwd, root, signal);
|
|
298
|
+
const libraryFrames = diagnosis.frames.filter((frame) => frame.library).length;
|
|
299
|
+
|
|
300
|
+
return text(
|
|
301
|
+
result(ctx.cwd, started, {
|
|
302
|
+
ok: diagnosis.kind !== 'unknown',
|
|
303
|
+
summary: `${diagnosis.kind}: ${diagnosis.summary}`,
|
|
304
|
+
data: {
|
|
305
|
+
...diagnosis,
|
|
306
|
+
libraryFrameCount: libraryFrames,
|
|
307
|
+
totalFrameCount: diagnosis.frames.length,
|
|
308
|
+
},
|
|
309
|
+
evidence: [
|
|
310
|
+
{
|
|
311
|
+
kind: 'failure_diagnosis',
|
|
312
|
+
failureKind: diagnosis.kind,
|
|
313
|
+
firstUserFrame: diagnosis.firstUserFrame ?? null,
|
|
314
|
+
libraryFrameCount: libraryFrames,
|
|
315
|
+
},
|
|
316
|
+
...diagnosis.evidence.map((entry) => ({ kind: 'failure_evidence', ...entry })),
|
|
317
|
+
],
|
|
318
|
+
warnings:
|
|
319
|
+
diagnosis.kind === 'unknown'
|
|
320
|
+
? [
|
|
321
|
+
{
|
|
322
|
+
code: 'UNCLASSIFIED_FAILURE',
|
|
323
|
+
message:
|
|
324
|
+
'The output did not match a known Python, pytest, or uv failure pattern.',
|
|
325
|
+
severity: 'warning' as const,
|
|
326
|
+
},
|
|
327
|
+
]
|
|
328
|
+
: [],
|
|
329
|
+
errors:
|
|
330
|
+
diagnosis.kind === 'unknown'
|
|
331
|
+
? []
|
|
332
|
+
: [
|
|
333
|
+
{
|
|
334
|
+
code: 'FAILURE_DIAGNOSED',
|
|
335
|
+
message: diagnosis.summary,
|
|
336
|
+
severity: 'error' as const,
|
|
337
|
+
path: diagnosis.firstUserFrame?.path,
|
|
338
|
+
line: diagnosis.firstUserFrame?.line,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
suggestions: diagnosis.suggestions,
|
|
342
|
+
projectRoot: root,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { failure, result, warn } from '../../src/core/result.ts';
|
|
3
|
+
import { runCommand } from '../../src/core/runner.ts';
|
|
4
|
+
import { pytestCommand, uvLockCheck, uvSyncFrozen } from '../../src/build/commands.ts';
|
|
5
|
+
import { diagnoseFailure } from '../../src/build/failure.ts';
|
|
6
|
+
import { parsePytestOutput } from '../../src/build/pytest.ts';
|
|
7
|
+
import { detectStaleArtifacts } from '../../src/build/staleness.ts';
|
|
8
|
+
import {
|
|
9
|
+
compareInstalledConformance,
|
|
10
|
+
requiredDeclarationsFrom,
|
|
11
|
+
} from '../../src/project/conformance.ts';
|
|
12
|
+
import { readInstalledDistributions } from '../../src/project/installed.ts';
|
|
13
|
+
import { summarizeValidation, type ValidationStep } from '../../src/validation/bundle.ts';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { isFile } from '../../src/project/root.ts';
|
|
16
|
+
import { runScanProject } from '../../src/project/scanner.ts';
|
|
17
|
+
import { messageOf, resolveProjectRoot, text, type Pi } from '../shared.ts';
|
|
18
|
+
|
|
19
|
+
function stepFrom(run: { code: number | null; timedOut: boolean }): ValidationStep {
|
|
20
|
+
return { executed: true, ok: run.code === 0 && !run.timedOut, exitCode: run.code };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function registerValidationTools(pi: Pi): void {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: 'py_sync',
|
|
26
|
+
label: 'Python Sync',
|
|
27
|
+
description:
|
|
28
|
+
'Preview or run uv lock --check or uv sync --frozen. Execution is opt-in because it modifies .venv. Does not edit sources.',
|
|
29
|
+
promptSnippet: 'Preview or run the uv environment sync',
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
'Use py_sync with execute=false to preview the uv command, and execute=true only when the environment must be created or refreshed.',
|
|
32
|
+
],
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
mode: Type.Optional(
|
|
35
|
+
Type.Union([Type.Literal('check'), Type.Literal('sync')], {
|
|
36
|
+
description: 'check runs uv lock --check; sync runs uv sync --frozen --all-groups.',
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
execute: Type.Optional(Type.Boolean()),
|
|
40
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 1800 })),
|
|
41
|
+
path: Type.Optional(Type.String()),
|
|
42
|
+
}),
|
|
43
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
44
|
+
const started = Date.now();
|
|
45
|
+
try {
|
|
46
|
+
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
47
|
+
const mode = params.mode ?? 'check';
|
|
48
|
+
const command = mode === 'check' ? uvLockCheck(ctx.cwd) : uvSyncFrozen(ctx.cwd);
|
|
49
|
+
const lockPresent = await isFile(join(root, 'uv.lock'));
|
|
50
|
+
|
|
51
|
+
if (!params.execute) {
|
|
52
|
+
return text(
|
|
53
|
+
result(ctx.cwd, started, {
|
|
54
|
+
ok: true,
|
|
55
|
+
summary: `${mode} command preview generated; nothing was executed.`,
|
|
56
|
+
data: { executed: false, mode, command, lockPresent },
|
|
57
|
+
evidence: [{ kind: 'command_preview', ...command }],
|
|
58
|
+
warnings: lockPresent
|
|
59
|
+
? []
|
|
60
|
+
: [
|
|
61
|
+
warn(
|
|
62
|
+
'LOCKFILE_MISSING',
|
|
63
|
+
'uv.lock does not exist, so uv lock --check cannot confirm the declared dependencies.',
|
|
64
|
+
root,
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
errors: [],
|
|
68
|
+
suggestions: [
|
|
69
|
+
{
|
|
70
|
+
message:
|
|
71
|
+
mode === 'check'
|
|
72
|
+
? 'Set execute=true to verify that uv.lock matches pyproject.toml.'
|
|
73
|
+
: 'Set execute=true to synchronise .venv from the lockfile.',
|
|
74
|
+
confidence: 'high' as const,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
commands: [command],
|
|
78
|
+
projectRoot: root,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const run = await runCommand(command.executable, command.args, {
|
|
84
|
+
cwd: ctx.cwd,
|
|
85
|
+
signal,
|
|
86
|
+
timeoutMs: (params.timeoutSeconds ?? 600) * 1000,
|
|
87
|
+
maxBytes: 256 * 1024,
|
|
88
|
+
});
|
|
89
|
+
const output = `${run.stdout}\n${run.stderr}`;
|
|
90
|
+
const diagnosis = run.code === 0 ? undefined : diagnoseFailure(output);
|
|
91
|
+
|
|
92
|
+
return text(
|
|
93
|
+
result(ctx.cwd, started, {
|
|
94
|
+
ok: run.code === 0,
|
|
95
|
+
summary: run.timedOut
|
|
96
|
+
? 'uv exceeded the time limit and was terminated.'
|
|
97
|
+
: run.code === 0
|
|
98
|
+
? mode === 'check'
|
|
99
|
+
? 'uv.lock matches pyproject.toml.'
|
|
100
|
+
: 'The environment was synchronised from the lockfile.'
|
|
101
|
+
: `uv ${mode} failed: ${diagnosis?.summary ?? 'see the captured output.'}`,
|
|
102
|
+
data: {
|
|
103
|
+
executed: true,
|
|
104
|
+
mode,
|
|
105
|
+
exitCode: run.code,
|
|
106
|
+
truncated: run.truncated,
|
|
107
|
+
timedOut: run.timedOut,
|
|
108
|
+
stdoutTail: run.stdout.slice(-4000),
|
|
109
|
+
stderrTail: run.stderr.slice(-4000),
|
|
110
|
+
diagnosis,
|
|
111
|
+
},
|
|
112
|
+
evidence: [{ kind: 'uv_command', mode, exitCode: run.code, executed: true }],
|
|
113
|
+
warnings: run.truncated
|
|
114
|
+
? [
|
|
115
|
+
{
|
|
116
|
+
code: 'OUTPUT_TRUNCATED',
|
|
117
|
+
message: 'Command output was truncated; only the tail is reported.',
|
|
118
|
+
severity: 'warning' as const,
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
: [],
|
|
122
|
+
errors:
|
|
123
|
+
run.code === 0
|
|
124
|
+
? []
|
|
125
|
+
: [
|
|
126
|
+
{
|
|
127
|
+
code: mode === 'check' ? 'LOCKFILE_OUT_OF_DATE' : 'SYNC_FAILED',
|
|
128
|
+
message:
|
|
129
|
+
diagnosis?.summary ??
|
|
130
|
+
run.stderr.trim().slice(0, 500) ??
|
|
131
|
+
'uv exited with a non-zero status.',
|
|
132
|
+
severity: 'error' as const,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
suggestions: diagnosis?.suggestions ?? [],
|
|
136
|
+
commands: [command],
|
|
137
|
+
truncated: run.truncated,
|
|
138
|
+
projectRoot: root,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pi.registerTool({
|
|
148
|
+
name: 'py_validation_bundle',
|
|
149
|
+
label: 'Python Validation Bundle',
|
|
150
|
+
description:
|
|
151
|
+
'Preview or run one evidence-oriented sequence: uv lock --check, uv sync --frozen, pytest, and a stale-artifact check. Execution is opt-in.',
|
|
152
|
+
promptSnippet: 'Run the Python sync and test validation bundle',
|
|
153
|
+
promptGuidelines: [
|
|
154
|
+
'Use py_validation_bundle with execute=false first; a preview is never a passing validation.',
|
|
155
|
+
'Use py_validation_bundle as the single completion gate after changing Python sources or dependencies.',
|
|
156
|
+
],
|
|
157
|
+
parameters: Type.Object({
|
|
158
|
+
targets: Type.Optional(Type.Array(Type.String())),
|
|
159
|
+
execute: Type.Optional(Type.Boolean()),
|
|
160
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 3600 })),
|
|
161
|
+
path: Type.Optional(Type.String()),
|
|
162
|
+
}),
|
|
163
|
+
async execute(_id, params, signal, _update, ctx) {
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
const root = (await resolveProjectRoot(ctx.cwd, params.path)) ?? ctx.cwd;
|
|
167
|
+
const lockPresent = await isFile(join(root, 'uv.lock'));
|
|
168
|
+
const lock = uvLockCheck(ctx.cwd);
|
|
169
|
+
const sync = uvSyncFrozen(ctx.cwd);
|
|
170
|
+
const test = pytestCommand(ctx.cwd, { targets: params.targets });
|
|
171
|
+
const timeoutMs = (params.timeoutSeconds ?? 1800) * 1000;
|
|
172
|
+
const commands = [lock, sync, test];
|
|
173
|
+
|
|
174
|
+
if (!params.execute) {
|
|
175
|
+
return text(
|
|
176
|
+
result(ctx.cwd, started, {
|
|
177
|
+
ok: true,
|
|
178
|
+
summary: `Validation sequence preview generated (${lockPresent ? 'uv lock --check, uv sync, pytest' : 'uv sync, pytest'}); nothing was executed.`,
|
|
179
|
+
data: { executed: false, lockPresent, steps: commands },
|
|
180
|
+
evidence: [
|
|
181
|
+
{
|
|
182
|
+
kind: 'validation_preview',
|
|
183
|
+
steps: commands.map((entry) => entry.args.join(' ')),
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
warnings: lockPresent
|
|
187
|
+
? []
|
|
188
|
+
: [
|
|
189
|
+
warn(
|
|
190
|
+
'LOCKFILE_MISSING',
|
|
191
|
+
'uv.lock does not exist; the lock check step cannot run and drift will not be detected.',
|
|
192
|
+
root,
|
|
193
|
+
),
|
|
194
|
+
],
|
|
195
|
+
errors: [],
|
|
196
|
+
suggestions: [
|
|
197
|
+
{
|
|
198
|
+
message: 'Set execute=true to run the sequence. This creates or refreshes .venv.',
|
|
199
|
+
confidence: 'high' as const,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
commands,
|
|
203
|
+
projectRoot: root,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const lockRun = lockPresent
|
|
209
|
+
? await runCommand(lock.executable, lock.args, {
|
|
210
|
+
cwd: ctx.cwd,
|
|
211
|
+
signal,
|
|
212
|
+
timeoutMs,
|
|
213
|
+
maxBytes: 256 * 1024,
|
|
214
|
+
})
|
|
215
|
+
: undefined;
|
|
216
|
+
const syncRun = await runCommand(sync.executable, sync.args, {
|
|
217
|
+
cwd: ctx.cwd,
|
|
218
|
+
signal,
|
|
219
|
+
timeoutMs,
|
|
220
|
+
maxBytes: 256 * 1024,
|
|
221
|
+
});
|
|
222
|
+
const testRun =
|
|
223
|
+
syncRun.code === 0
|
|
224
|
+
? await runCommand(test.executable, test.args, {
|
|
225
|
+
cwd: ctx.cwd,
|
|
226
|
+
signal,
|
|
227
|
+
timeoutMs,
|
|
228
|
+
maxBytes: 512 * 1024,
|
|
229
|
+
})
|
|
230
|
+
: undefined;
|
|
231
|
+
|
|
232
|
+
const report = testRun ? parsePytestOutput(testRun.stdout, testRun.stderr) : undefined;
|
|
233
|
+
const scan = await runScanProject(ctx.cwd, { root, mode: 'manifest' }, signal);
|
|
234
|
+
const installed = await readInstalledDistributions(join(root, '.venv'));
|
|
235
|
+
const conformance = compareInstalledConformance({
|
|
236
|
+
lock: scan.payload?.lock,
|
|
237
|
+
installed,
|
|
238
|
+
projectName: scan.payload?.manifest?.name ?? undefined,
|
|
239
|
+
requiredDeclarations: requiredDeclarationsFrom(scan.payload?.manifest),
|
|
240
|
+
});
|
|
241
|
+
const staleness = await detectStaleArtifacts(root);
|
|
242
|
+
|
|
243
|
+
const lockStep: ValidationStep = lockRun
|
|
244
|
+
? stepFrom(lockRun)
|
|
245
|
+
: { executed: false, ok: false, exitCode: null };
|
|
246
|
+
const syncStep = stepFrom(syncRun);
|
|
247
|
+
const testStep: ValidationStep = testRun
|
|
248
|
+
? {
|
|
249
|
+
...stepFrom(testRun),
|
|
250
|
+
failures: report ? report.counts.failed + report.counts.errors : undefined,
|
|
251
|
+
}
|
|
252
|
+
: { executed: false, ok: false, exitCode: null };
|
|
253
|
+
|
|
254
|
+
const summary = summarizeValidation({
|
|
255
|
+
lock: lockStep,
|
|
256
|
+
sync: syncStep,
|
|
257
|
+
test: testStep,
|
|
258
|
+
conformance: conformance.verdict,
|
|
259
|
+
stale: staleness.stale,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const diagnosis =
|
|
263
|
+
testRun && testStep.ok === false
|
|
264
|
+
? diagnoseFailure(`${testRun.stdout}\n${testRun.stderr}`)
|
|
265
|
+
: undefined;
|
|
266
|
+
|
|
267
|
+
return text(
|
|
268
|
+
result(ctx.cwd, started, {
|
|
269
|
+
ok: summary.ok,
|
|
270
|
+
summary: summary.reason,
|
|
271
|
+
data: {
|
|
272
|
+
executed: true,
|
|
273
|
+
checks: summary.checks,
|
|
274
|
+
lock: { ...lockStep, present: lockPresent },
|
|
275
|
+
sync: syncStep,
|
|
276
|
+
test: {
|
|
277
|
+
...testStep,
|
|
278
|
+
counts: report?.counts,
|
|
279
|
+
failures: report?.failures.slice(0, 20),
|
|
280
|
+
},
|
|
281
|
+
conformance,
|
|
282
|
+
staleArtifacts: staleness,
|
|
283
|
+
firstFailure: diagnosis,
|
|
284
|
+
},
|
|
285
|
+
evidence: [
|
|
286
|
+
{
|
|
287
|
+
kind: 'validation_bundle',
|
|
288
|
+
checks: summary.checks,
|
|
289
|
+
lockExitCode: lockStep.exitCode ?? null,
|
|
290
|
+
syncExitCode: syncStep.exitCode ?? null,
|
|
291
|
+
testExitCode: testStep.exitCode ?? null,
|
|
292
|
+
testCounts: report?.counts ?? null,
|
|
293
|
+
conformanceVerdict: conformance.verdict,
|
|
294
|
+
stale: staleness.stale,
|
|
295
|
+
},
|
|
296
|
+
],
|
|
297
|
+
warnings: [
|
|
298
|
+
...conformance.warnings,
|
|
299
|
+
...staleness.artifacts.map((artifact) => ({
|
|
300
|
+
code: artifact.code,
|
|
301
|
+
message: artifact.message,
|
|
302
|
+
severity: 'warning' as const,
|
|
303
|
+
path: artifact.path,
|
|
304
|
+
})),
|
|
305
|
+
...(staleness.incompleteReason
|
|
306
|
+
? [
|
|
307
|
+
{
|
|
308
|
+
code: 'STALENESS_UNVERIFIED',
|
|
309
|
+
message: `Stale-artifact check was incomplete: ${staleness.incompleteReason}`,
|
|
310
|
+
severity: 'warning' as const,
|
|
311
|
+
},
|
|
312
|
+
]
|
|
313
|
+
: []),
|
|
314
|
+
],
|
|
315
|
+
errors: summary.ok
|
|
316
|
+
? []
|
|
317
|
+
: [
|
|
318
|
+
{
|
|
319
|
+
code: 'VALIDATION_FAILED',
|
|
320
|
+
message: summary.reason,
|
|
321
|
+
severity: 'error' as const,
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
suggestions: summary.ok
|
|
325
|
+
? []
|
|
326
|
+
: [
|
|
327
|
+
{
|
|
328
|
+
message:
|
|
329
|
+
'Fix the failing step before reporting completion; a partial run is not evidence.',
|
|
330
|
+
confidence: 'high' as const,
|
|
331
|
+
},
|
|
332
|
+
...(diagnosis?.suggestions ?? []),
|
|
333
|
+
],
|
|
334
|
+
commands,
|
|
335
|
+
projectRoot: root,
|
|
336
|
+
pythonVersion: scan.payload?.pythonVersion,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
return text(failure(ctx.cwd, started, messageOf(error), 'INTERNAL_ERROR'));
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|