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,343 @@
|
|
|
1
|
+
import { type Diagnostic, warn } from '../core/result.ts';
|
|
2
|
+
import type { InstalledEnvironment } from './installed.ts';
|
|
3
|
+
import { normalizeDistributionName } from './installed.ts';
|
|
4
|
+
import type { LockPackage, LockSection, ManifestSection } from './scanner.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Declared dependencies that `uv sync` installs without extra flags:
|
|
8
|
+
* `[project] dependencies` plus the default `dev` dependency group. Optional
|
|
9
|
+
* extras are excluded because a plain sync never installs them.
|
|
10
|
+
*/
|
|
11
|
+
export function requiredDeclarationsFrom(
|
|
12
|
+
manifest: ManifestSection | undefined,
|
|
13
|
+
defaultGroup = 'dev',
|
|
14
|
+
): { name: string; normalized: string; marker: string | null }[] {
|
|
15
|
+
if (!manifest) return [];
|
|
16
|
+
const groups = [manifest.dependencies, manifest.dependencyGroups[defaultGroup] ?? []];
|
|
17
|
+
return groups.flatMap((list) =>
|
|
18
|
+
list.map((dependency) => ({
|
|
19
|
+
name: dependency.name,
|
|
20
|
+
normalized: dependency.normalized,
|
|
21
|
+
marker: dependency.marker,
|
|
22
|
+
})),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ConformanceCode =
|
|
27
|
+
| 'INSTALLED_VERSION_MISMATCH'
|
|
28
|
+
| 'INSTALLED_PACKAGE_MISSING'
|
|
29
|
+
| 'INSTALLED_PACKAGE_UNTRACKED'
|
|
30
|
+
| 'INSTALLED_ENVIRONMENT_INDEPENDENT'
|
|
31
|
+
| 'PROJECT_NOT_INSTALLED'
|
|
32
|
+
| 'PROJECT_INSTALLED_NOT_EDITABLE';
|
|
33
|
+
|
|
34
|
+
export interface ConformanceFinding {
|
|
35
|
+
code: ConformanceCode;
|
|
36
|
+
message: string;
|
|
37
|
+
name: string;
|
|
38
|
+
expected?: string;
|
|
39
|
+
actual?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ConformanceReport {
|
|
43
|
+
verdict: 'consistent' | 'drifted' | 'unverifiable';
|
|
44
|
+
/** False whenever the comparison could not cover every distribution. */
|
|
45
|
+
complete: boolean;
|
|
46
|
+
reason: string;
|
|
47
|
+
checks: {
|
|
48
|
+
venvPresent: boolean;
|
|
49
|
+
lockPresent: boolean;
|
|
50
|
+
installedScanned: boolean;
|
|
51
|
+
projectInstalled: boolean | null;
|
|
52
|
+
projectEditable: boolean | null;
|
|
53
|
+
};
|
|
54
|
+
counts: {
|
|
55
|
+
lockPackages: number;
|
|
56
|
+
installedPackages: number;
|
|
57
|
+
mismatched: number;
|
|
58
|
+
missing: number;
|
|
59
|
+
/** Locked entries that are conditional for this platform and correctly absent. */
|
|
60
|
+
conditional: number;
|
|
61
|
+
untracked: number;
|
|
62
|
+
};
|
|
63
|
+
findings: ConformanceFinding[];
|
|
64
|
+
warnings: Diagnostic[];
|
|
65
|
+
notes: Diagnostic[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Untracked packages are only reported individually while they look like an
|
|
70
|
+
* anomaly. Hundreds of them mean the environment is not managed by this
|
|
71
|
+
* lockfile at all (a shared or conda environment), which deserves one summary
|
|
72
|
+
* instead of a wall of warnings.
|
|
73
|
+
*/
|
|
74
|
+
const INDEPENDENT_ENVIRONMENT_MIN = 20;
|
|
75
|
+
const INDEPENDENT_ENVIRONMENT_RATIO = 0.5;
|
|
76
|
+
|
|
77
|
+
/** Lock entries that are a local project rather than an index download. */
|
|
78
|
+
function isLocalProjectEntry(package_: LockPackage): boolean {
|
|
79
|
+
return package_.source === 'editable' || package_.source === 'virtual';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The entry that represents the project this command is running in. */
|
|
83
|
+
function isRootProjectEntry(package_: LockPackage, projectName: string | undefined): boolean {
|
|
84
|
+
if (!isLocalProjectEntry(package_)) return false;
|
|
85
|
+
if (!projectName) return true;
|
|
86
|
+
return package_.normalized === normalizeDistributionName(projectName);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ConformanceInput {
|
|
90
|
+
lock?: LockSection;
|
|
91
|
+
installed?: InstalledEnvironment;
|
|
92
|
+
projectName?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Declared dependencies that `uv sync` installs without extra flags:
|
|
95
|
+
* `[project] dependencies` plus the default `dev` group. Used to decide
|
|
96
|
+
* whether an absent locked package is genuinely missing.
|
|
97
|
+
*/
|
|
98
|
+
requiredDeclarations?: { name: string; normalized: string; marker: string | null }[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Names that must be installed: a declaration with no marker, or an edge
|
|
103
|
+
* without a marker coming from the root project or from a package that is
|
|
104
|
+
* itself installed. Everything else is platform- or version-conditional and is
|
|
105
|
+
* correctly absent, so its absence is not drift.
|
|
106
|
+
*/
|
|
107
|
+
function requiredInstalledNames(
|
|
108
|
+
lock: LockSection,
|
|
109
|
+
installedNormalized: Set<string>,
|
|
110
|
+
input: ConformanceInput,
|
|
111
|
+
): Set<string> {
|
|
112
|
+
const required = new Set<string>();
|
|
113
|
+
for (const declaration of input.requiredDeclarations ?? []) {
|
|
114
|
+
if (declaration.marker === null) required.add(declaration.normalized);
|
|
115
|
+
}
|
|
116
|
+
for (const entry of lock.packages) {
|
|
117
|
+
const isReachableSource =
|
|
118
|
+
isRootProjectEntry(entry, input.projectName) || installedNormalized.has(entry.normalized);
|
|
119
|
+
if (!isReachableSource) continue;
|
|
120
|
+
for (const edge of entry.dependencies ?? []) {
|
|
121
|
+
if (edge.marker === null) required.add(edge.normalized);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return required;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compare the three sources of truth that describe a Python environment:
|
|
129
|
+
* what `pyproject.toml` declares, what `uv.lock` resolved, and what is actually
|
|
130
|
+
* present in `.venv`.
|
|
131
|
+
*
|
|
132
|
+
* Only the lock-versus-installed leg is computed here; declared-versus-lock is
|
|
133
|
+
* reported by the manifest drift check, so the two never duplicate a finding.
|
|
134
|
+
*/
|
|
135
|
+
export function compareInstalledConformance(input: ConformanceInput): ConformanceReport {
|
|
136
|
+
const { lock, installed, projectName } = input;
|
|
137
|
+
const findings: ConformanceFinding[] = [];
|
|
138
|
+
const warnings: Diagnostic[] = [];
|
|
139
|
+
const notes: Diagnostic[] = [];
|
|
140
|
+
const venvPresent = installed !== undefined;
|
|
141
|
+
const lockPresent = lock?.present ?? false;
|
|
142
|
+
const installedPackages = installed?.count ?? 0;
|
|
143
|
+
|
|
144
|
+
const checks = {
|
|
145
|
+
venvPresent,
|
|
146
|
+
lockPresent,
|
|
147
|
+
installedScanned: venvPresent,
|
|
148
|
+
projectInstalled: null as boolean | null,
|
|
149
|
+
projectEditable: null as boolean | null,
|
|
150
|
+
};
|
|
151
|
+
const counts = {
|
|
152
|
+
lockPackages: lock?.packages.length ?? 0,
|
|
153
|
+
installedPackages,
|
|
154
|
+
mismatched: 0,
|
|
155
|
+
missing: 0,
|
|
156
|
+
conditional: 0,
|
|
157
|
+
untracked: 0,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (const message of installed?.warnings ?? []) {
|
|
161
|
+
warnings.push(warn('INSTALLED_SCAN_WARNING', message));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const unverifiable = (reason: string): ConformanceReport => ({
|
|
165
|
+
verdict: 'unverifiable',
|
|
166
|
+
complete: false,
|
|
167
|
+
reason,
|
|
168
|
+
checks,
|
|
169
|
+
counts,
|
|
170
|
+
findings,
|
|
171
|
+
warnings,
|
|
172
|
+
notes,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!venvPresent) {
|
|
176
|
+
return unverifiable(
|
|
177
|
+
'No .venv with a site-packages directory was found, so installed versions cannot be compared with uv.lock.',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (!lockPresent || !lock) {
|
|
181
|
+
return unverifiable(
|
|
182
|
+
'uv.lock is missing, so there is no expected version set to compare the installed distributions against.',
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const installedByNormalized = new Map(
|
|
187
|
+
(installed?.distributions ?? []).map((entry) => [entry.normalized, entry]),
|
|
188
|
+
);
|
|
189
|
+
const lockByNormalized = new Map(lock.packages.map((entry) => [entry.normalized, entry]));
|
|
190
|
+
const required = requiredInstalledNames(lock, new Set(installedByNormalized.keys()), input);
|
|
191
|
+
const conditionalAbsent: string[] = [];
|
|
192
|
+
|
|
193
|
+
for (const entry of lock.packages) {
|
|
194
|
+
const actual = installedByNormalized.get(entry.normalized);
|
|
195
|
+
const localProject = isLocalProjectEntry(entry);
|
|
196
|
+
const rootProject = isRootProjectEntry(entry, projectName);
|
|
197
|
+
|
|
198
|
+
if (!actual) {
|
|
199
|
+
if (localProject) {
|
|
200
|
+
counts.missing += 1;
|
|
201
|
+
if (rootProject) checks.projectInstalled = false;
|
|
202
|
+
findings.push({
|
|
203
|
+
code: 'PROJECT_NOT_INSTALLED',
|
|
204
|
+
name: entry.name,
|
|
205
|
+
expected: entry.version ?? undefined,
|
|
206
|
+
message: rootProject
|
|
207
|
+
? `uv.lock records "${entry.name}" as an editable install, but it is absent from .venv. The project is not importable and no test can exercise it.`
|
|
208
|
+
: `uv.lock records the local project "${entry.name}" as an editable install, but it is absent from .venv.`,
|
|
209
|
+
});
|
|
210
|
+
} else if (required.has(entry.normalized)) {
|
|
211
|
+
counts.missing += 1;
|
|
212
|
+
findings.push({
|
|
213
|
+
code: 'INSTALLED_PACKAGE_MISSING',
|
|
214
|
+
name: entry.name,
|
|
215
|
+
expected: entry.version ?? undefined,
|
|
216
|
+
message: `"${entry.name}" is locked and required unconditionally, but it is not installed in .venv.`,
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
// Guarded by a platform or version marker, so this platform rightly omits it.
|
|
220
|
+
counts.conditional += 1;
|
|
221
|
+
conditionalAbsent.push(`${entry.name}@${entry.version ?? '?'}`);
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (localProject) {
|
|
227
|
+
if (rootProject) checks.projectInstalled = true;
|
|
228
|
+
if (actual.source !== 'editable') {
|
|
229
|
+
if (rootProject) checks.projectEditable = false;
|
|
230
|
+
findings.push({
|
|
231
|
+
code: 'PROJECT_INSTALLED_NOT_EDITABLE',
|
|
232
|
+
name: entry.name,
|
|
233
|
+
actual: actual.version,
|
|
234
|
+
message: rootProject
|
|
235
|
+
? `"${entry.name}" is installed from a materialised copy instead of an editable link, so tests would import a stale snapshot of the sources.`
|
|
236
|
+
: `The local project "${entry.name}" is installed from a materialised copy instead of an editable link.`,
|
|
237
|
+
});
|
|
238
|
+
} else if (rootProject) {
|
|
239
|
+
checks.projectEditable = true;
|
|
240
|
+
}
|
|
241
|
+
// The editable version tracks pyproject.toml, so a version difference here
|
|
242
|
+
// is lockfile drift and is reported by the manifest check instead.
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (entry.version && actual.version !== entry.version) {
|
|
247
|
+
counts.mismatched += 1;
|
|
248
|
+
findings.push({
|
|
249
|
+
code: 'INSTALLED_VERSION_MISMATCH',
|
|
250
|
+
name: entry.name,
|
|
251
|
+
expected: entry.version,
|
|
252
|
+
actual: actual.version,
|
|
253
|
+
message: `"${entry.name}" is locked at ${entry.version} but ${actual.version} is installed in .venv.`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const untrackedCandidates = (installed?.distributions ?? []).filter(
|
|
259
|
+
(entry) => !entry.bootstrap && !lockByNormalized.has(entry.normalized),
|
|
260
|
+
);
|
|
261
|
+
const nonBootstrap = (installed?.distributions ?? []).filter((entry) => !entry.bootstrap).length;
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
untrackedCandidates.length >= INDEPENDENT_ENVIRONMENT_MIN &&
|
|
265
|
+
nonBootstrap > 0 &&
|
|
266
|
+
untrackedCandidates.length / nonBootstrap > INDEPENDENT_ENVIRONMENT_RATIO
|
|
267
|
+
) {
|
|
268
|
+
counts.untracked = untrackedCandidates.length;
|
|
269
|
+
findings.push({
|
|
270
|
+
code: 'INSTALLED_ENVIRONMENT_INDEPENDENT',
|
|
271
|
+
name: installed?.sitePackages ?? '',
|
|
272
|
+
actual: String(untrackedCandidates.length),
|
|
273
|
+
message: `${untrackedCandidates.length} of ${nonBootstrap} installed distributions are absent from uv.lock, which suggests .venv was not created by uv for this project.`,
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
for (const entry of untrackedCandidates) {
|
|
277
|
+
counts.untracked += 1;
|
|
278
|
+
findings.push({
|
|
279
|
+
code: 'INSTALLED_PACKAGE_UNTRACKED',
|
|
280
|
+
name: entry.name,
|
|
281
|
+
actual: entry.version,
|
|
282
|
+
message: `"${entry.name}" ${entry.version} is installed in .venv but is absent from uv.lock (${entry.source === 'editable' ? 'editable install' : 'installed copy'}).`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (conditionalAbsent.length > 0) {
|
|
288
|
+
notes.push({
|
|
289
|
+
code: 'CONDITIONAL_PACKAGES_ABSENT',
|
|
290
|
+
message: `${conditionalAbsent.length} locked distribution(s) are guarded by a platform or version marker and are correctly absent here: ${conditionalAbsent.slice(0, 8).join(', ')}${conditionalAbsent.length > 8 ? ', …' : ''}.`,
|
|
291
|
+
severity: 'info',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const bootstrapCount = (installed?.distributions ?? []).filter((entry) => entry.bootstrap).length;
|
|
296
|
+
if (bootstrapCount > 0) {
|
|
297
|
+
notes.push({
|
|
298
|
+
code: 'BOOTSTRAP_DISTRIBUTIONS_SKIPPED',
|
|
299
|
+
message: `${bootstrapCount} interpreter-seeded distribution(s) (pip/setuptools and similar) were excluded from the comparison.`,
|
|
300
|
+
severity: 'info',
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const recovered = (installed?.distributions ?? []).filter(
|
|
304
|
+
(entry) => entry.recoveredFromDirectory,
|
|
305
|
+
).length;
|
|
306
|
+
if (recovered > 0) {
|
|
307
|
+
notes.push({
|
|
308
|
+
code: 'VERSION_FROM_DIRECTORY_NAME',
|
|
309
|
+
message: `${recovered} distribution(s) had no readable METADATA version; the version came from the dist-info directory name.`,
|
|
310
|
+
severity: 'info',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const finding of findings) {
|
|
315
|
+
warnings.push(warn(finding.code, finding.message));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const complete = !(installed?.truncated ?? false);
|
|
319
|
+
if (!complete) {
|
|
320
|
+
notes.push({
|
|
321
|
+
code: 'CONFORMANCE_INCOMPLETE',
|
|
322
|
+
message:
|
|
323
|
+
'The installed-distribution scan was truncated, so the comparison does not cover the whole environment.',
|
|
324
|
+
severity: 'info',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const drifted = findings.length > 0;
|
|
329
|
+
return {
|
|
330
|
+
verdict: drifted ? 'drifted' : complete ? 'consistent' : 'unverifiable',
|
|
331
|
+
complete,
|
|
332
|
+
reason: drifted
|
|
333
|
+
? `${findings.length} environment conformance problem(s) found: ${counts.mismatched} version mismatch(es), ${counts.missing} missing, ${counts.untracked} untracked.`
|
|
334
|
+
: complete
|
|
335
|
+
? `All ${counts.lockPackages - counts.conditional} unconditional locked distribution(s) match the ${installedPackages} installed distribution(s)${counts.conditional > 0 ? ` (${counts.conditional} conditional entry/entries correctly absent)` : ''}.`
|
|
336
|
+
: 'The lock and installed distributions agree where they could be compared, but the scan was truncated.',
|
|
337
|
+
checks,
|
|
338
|
+
counts,
|
|
339
|
+
findings,
|
|
340
|
+
warnings,
|
|
341
|
+
notes,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { type Diagnostic, type Suggestion, warn } from '../core/result.ts';
|
|
2
|
+
import {
|
|
3
|
+
compareInstalledConformance,
|
|
4
|
+
requiredDeclarationsFrom,
|
|
5
|
+
type ConformanceReport,
|
|
6
|
+
} from './conformance.ts';
|
|
7
|
+
import type { InstalledEnvironment } from './installed.ts';
|
|
8
|
+
import type { ScanPayload } from './scanner.ts';
|
|
9
|
+
|
|
10
|
+
export interface ProjectInspection {
|
|
11
|
+
root: string;
|
|
12
|
+
pyproject?: string;
|
|
13
|
+
uvLock?: string;
|
|
14
|
+
venvDir?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
requiresPython?: string;
|
|
18
|
+
layout: 'src' | 'flat';
|
|
19
|
+
modules: string[];
|
|
20
|
+
importName?: string;
|
|
21
|
+
buildBackend?: string;
|
|
22
|
+
entryPoints: string[];
|
|
23
|
+
toolConfiguration: Record<string, boolean>;
|
|
24
|
+
uvWorkspaceMembers: string[];
|
|
25
|
+
uvSources: string[];
|
|
26
|
+
requirementsFiles: string[];
|
|
27
|
+
dependencyCounts: {
|
|
28
|
+
runtime: number;
|
|
29
|
+
optional: Record<string, number>;
|
|
30
|
+
groups: Record<string, number>;
|
|
31
|
+
};
|
|
32
|
+
lock: { present: boolean; path?: string; packageCount: number };
|
|
33
|
+
/** Lock-versus-installed comparison; `undefined` when no environment was scanned. */
|
|
34
|
+
conformance?: ConformanceReport;
|
|
35
|
+
installed?: { sitePackages: string; count: number; editableCount: number };
|
|
36
|
+
warnings: Diagnostic[];
|
|
37
|
+
notes: Diagnostic[];
|
|
38
|
+
suggestions: Suggestion[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InspectInput {
|
|
42
|
+
payload: ScanPayload;
|
|
43
|
+
venvDir?: string;
|
|
44
|
+
/** `undefined` when no .gitignore exists, so the check stays honest. */
|
|
45
|
+
venvIgnored?: boolean;
|
|
46
|
+
hasTestsDirectory: boolean;
|
|
47
|
+
/** Distributions read from `.venv`; omit to skip the conformance comparison. */
|
|
48
|
+
installed?: InstalledEnvironment;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Turn a scanner payload into a project model plus diagnostics. Pure so the
|
|
53
|
+
* whole diagnostic surface is unit-testable without touching a filesystem.
|
|
54
|
+
*/
|
|
55
|
+
export function inspectProject(input: InspectInput): ProjectInspection {
|
|
56
|
+
const { payload, venvDir, venvIgnored, hasTestsDirectory, installed } = input;
|
|
57
|
+
const manifest = payload.manifest;
|
|
58
|
+
const lock = payload.lock;
|
|
59
|
+
const comparison = payload.lockComparison;
|
|
60
|
+
const warnings: Diagnostic[] = [];
|
|
61
|
+
const notes: Diagnostic[] = [];
|
|
62
|
+
const suggestions: Suggestion[] = [];
|
|
63
|
+
|
|
64
|
+
const root = payload.root;
|
|
65
|
+
if (manifest?.tomlError) {
|
|
66
|
+
warnings.push(
|
|
67
|
+
warn('TOML_PARSE_ERROR', manifest.tomlError, manifest.pyprojectPath ?? undefined),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
for (const message of manifest?.warnings ?? []) {
|
|
71
|
+
warnings.push(warn('MANIFEST_WARNING', message, manifest?.pyprojectPath ?? undefined));
|
|
72
|
+
}
|
|
73
|
+
for (const message of lock?.warnings ?? []) {
|
|
74
|
+
notes.push({ code: 'LOCKFILE_NOTE', message, severity: 'info' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!manifest?.pyprojectPath) {
|
|
78
|
+
warnings.push(
|
|
79
|
+
warn(
|
|
80
|
+
'PYPROJECT_MISSING',
|
|
81
|
+
'pyproject.toml was not found; dependencies, layout, and tool configuration cannot be verified.',
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
suggestions.push({
|
|
85
|
+
message: 'Run uv init to create a pyproject.toml, then uv add the runtime dependencies.',
|
|
86
|
+
confidence: 'high',
|
|
87
|
+
command: 'uv init',
|
|
88
|
+
});
|
|
89
|
+
} else if (!manifest.name) {
|
|
90
|
+
warnings.push(
|
|
91
|
+
warn(
|
|
92
|
+
'PROJECT_NAME_MISSING',
|
|
93
|
+
'pyproject.toml has no [project] name, so the installed distribution name is unknown.',
|
|
94
|
+
manifest.pyprojectPath ?? undefined,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
suggestions.push({
|
|
98
|
+
message: 'Add a [project] table with name and version to pyproject.toml.',
|
|
99
|
+
confidence: 'high',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (manifest?.legacySetupPy || manifest?.legacySetupCfg) {
|
|
104
|
+
warnings.push(
|
|
105
|
+
warn(
|
|
106
|
+
'LEGACY_PACKAGING',
|
|
107
|
+
`The project still uses ${[
|
|
108
|
+
manifest.legacySetupPy ? 'setup.py' : '',
|
|
109
|
+
manifest.legacySetupCfg ? 'setup.cfg' : '',
|
|
110
|
+
]
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join(' and ')}; uv reads dependency metadata from pyproject.toml only.`,
|
|
113
|
+
root,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
suggestions.push({
|
|
117
|
+
message: 'Move dependency metadata from setup.py/setup.cfg into [project] in pyproject.toml.',
|
|
118
|
+
confidence: 'medium',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (manifest?.requirementsFiles.length && manifest.pyprojectPath) {
|
|
123
|
+
warnings.push(
|
|
124
|
+
warn(
|
|
125
|
+
'DUPLICATE_DEPENDENCY_SOURCE',
|
|
126
|
+
`${manifest.requirementsFiles.join(', ')} also declares dependencies; uv resolves from pyproject.toml and uv.lock only.`,
|
|
127
|
+
root,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!lock?.present) {
|
|
133
|
+
notes.push({
|
|
134
|
+
code: 'LOCKFILE_MISSING',
|
|
135
|
+
message:
|
|
136
|
+
'uv.lock was not found, so dependency drift and exact resolved versions cannot be verified.',
|
|
137
|
+
severity: 'info',
|
|
138
|
+
});
|
|
139
|
+
suggestions.push({
|
|
140
|
+
message: 'Run uv lock to record resolved versions in uv.lock.',
|
|
141
|
+
confidence: 'high',
|
|
142
|
+
command: 'uv lock',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (comparison?.requiresPythonMismatch) {
|
|
147
|
+
warnings.push(
|
|
148
|
+
warn(
|
|
149
|
+
'REQUIRES_PYTHON_MISMATCH',
|
|
150
|
+
`pyproject.toml requires-python is "${comparison.requiresPythonMismatch.manifest}" but uv.lock records "${comparison.requiresPythonMismatch.lock}".`,
|
|
151
|
+
lock?.path ?? undefined,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
suggestions.push({
|
|
155
|
+
message: 'Run uv lock so the lockfile reflects the current requires-python constraint.',
|
|
156
|
+
confidence: 'high',
|
|
157
|
+
command: 'uv lock',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const name of comparison?.missingFromLock ?? []) {
|
|
162
|
+
warnings.push(
|
|
163
|
+
warn(
|
|
164
|
+
'LOCKFILE_MISSING_DEPENDENCY',
|
|
165
|
+
`"${name}" is declared in pyproject.toml but absent from uv.lock.`,
|
|
166
|
+
lock?.path ?? undefined,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (comparison?.missingFromLock.length) {
|
|
171
|
+
suggestions.push({
|
|
172
|
+
message: 'Run uv lock to add the missing declarations to the lockfile.',
|
|
173
|
+
confidence: 'high',
|
|
174
|
+
command: 'uv lock',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const entry of comparison?.unsatisfiedInLock ?? []) {
|
|
179
|
+
warnings.push(
|
|
180
|
+
warn(
|
|
181
|
+
'LOCKFILE_UNSATISFIED_DEPENDENCY',
|
|
182
|
+
`"${entry.name}" is locked at ${entry.locked} which does not satisfy "${entry.specifier}".`,
|
|
183
|
+
lock?.path ?? undefined,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (lock?.present && comparison && !comparison.specifierCheckAvailable) {
|
|
189
|
+
notes.push({
|
|
190
|
+
code: 'SPECIFIER_CHECK_UNAVAILABLE',
|
|
191
|
+
message:
|
|
192
|
+
'The packaging library was unavailable, so only declared-versus-locked names were compared, not version constraints.',
|
|
193
|
+
severity: 'info',
|
|
194
|
+
});
|
|
195
|
+
suggestions.push({
|
|
196
|
+
message:
|
|
197
|
+
'Install the packaging library in the analysing interpreter to compare declared version constraints against uv.lock.',
|
|
198
|
+
confidence: 'medium',
|
|
199
|
+
command: 'python3 -m pip install packaging',
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!venvDir) {
|
|
204
|
+
notes.push({
|
|
205
|
+
code: 'VENV_MISSING',
|
|
206
|
+
message: 'No .venv directory exists at the project root; run uv sync before running tests.',
|
|
207
|
+
severity: 'info',
|
|
208
|
+
});
|
|
209
|
+
} else if (venvIgnored === false) {
|
|
210
|
+
warnings.push(
|
|
211
|
+
warn(
|
|
212
|
+
'VENV_NOT_IGNORED',
|
|
213
|
+
'.venv exists but is not listed in .gitignore.',
|
|
214
|
+
`${root}/.gitignore`,
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
suggestions.push({
|
|
218
|
+
message: 'Add .venv/ to .gitignore so the environment is never committed.',
|
|
219
|
+
confidence: 'high',
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!hasTestsDirectory) {
|
|
224
|
+
notes.push({
|
|
225
|
+
code: 'TESTS_DIRECTORY_MISSING',
|
|
226
|
+
message:
|
|
227
|
+
'No tests/ directory was found; test selection and TDD gates cannot match changed sources.',
|
|
228
|
+
severity: 'info',
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (manifest?.pyprojectPath && manifest.toolConfiguration && !manifest.toolConfiguration.pytest) {
|
|
233
|
+
notes.push({
|
|
234
|
+
code: 'PYTEST_NOT_CONFIGURED',
|
|
235
|
+
message: 'pyproject.toml has no [tool.pytest.ini_options] table.',
|
|
236
|
+
severity: 'info',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (manifest?.layout === 'src' && manifest.modules.length === 0) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
warn(
|
|
243
|
+
'EMPTY_SRC_LAYOUT',
|
|
244
|
+
'The src/ directory exists but contains no importable module directories or modules.',
|
|
245
|
+
`${root}/src`,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (manifest?.legacySetupPy && manifest.buildBackend === null && !manifest.pyprojectPath) {
|
|
251
|
+
suggestions.push({
|
|
252
|
+
message: 'uv manages dependencies from pyproject.toml; migrate before running uv sync.',
|
|
253
|
+
confidence: 'medium',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (manifest?.uvWorkspaceMembers.length) {
|
|
258
|
+
notes.push({
|
|
259
|
+
code: 'UV_WORKSPACE',
|
|
260
|
+
message: `This is a uv workspace with ${manifest.uvWorkspaceMembers.length} member(s); scope build and test tools per member.`,
|
|
261
|
+
severity: 'info',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const conformance =
|
|
266
|
+
installed !== undefined
|
|
267
|
+
? compareInstalledConformance({
|
|
268
|
+
lock,
|
|
269
|
+
installed,
|
|
270
|
+
projectName: manifest?.name ?? undefined,
|
|
271
|
+
requiredDeclarations: requiredDeclarationsFrom(manifest),
|
|
272
|
+
})
|
|
273
|
+
: undefined;
|
|
274
|
+
if (conformance) {
|
|
275
|
+
warnings.push(...conformance.warnings);
|
|
276
|
+
notes.push(...conformance.notes);
|
|
277
|
+
if (conformance.findings.some((finding) => finding.code === 'PROJECT_NOT_INSTALLED')) {
|
|
278
|
+
suggestions.push({
|
|
279
|
+
message:
|
|
280
|
+
'The project is not installed in .venv. Run uv sync; if that fails, the build backend could not find the package (check that the module directory name matches [project] name).',
|
|
281
|
+
confidence: 'high',
|
|
282
|
+
command: 'uv sync',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
conformance.findings.some(
|
|
287
|
+
(finding) =>
|
|
288
|
+
finding.code === 'INSTALLED_VERSION_MISMATCH' ||
|
|
289
|
+
finding.code === 'INSTALLED_PACKAGE_MISSING' ||
|
|
290
|
+
finding.code === 'PROJECT_INSTALLED_NOT_EDITABLE',
|
|
291
|
+
)
|
|
292
|
+
) {
|
|
293
|
+
suggestions.push({
|
|
294
|
+
message: 'Synchronise .venv with the lockfile so tests run against the locked versions.',
|
|
295
|
+
confidence: 'high',
|
|
296
|
+
command: 'uv sync --frozen',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const optionalCounts: Record<string, number> = {};
|
|
302
|
+
for (const [key, value] of Object.entries(manifest?.optionalDependencies ?? {})) {
|
|
303
|
+
optionalCounts[key] = value.length;
|
|
304
|
+
}
|
|
305
|
+
const groupCounts: Record<string, number> = {};
|
|
306
|
+
for (const [key, value] of Object.entries(manifest?.dependencyGroups ?? {})) {
|
|
307
|
+
groupCounts[key] = value.length;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
root,
|
|
312
|
+
pyproject: manifest?.pyprojectPath ?? undefined,
|
|
313
|
+
uvLock: lock?.present ? (lock.path ?? undefined) : undefined,
|
|
314
|
+
venvDir,
|
|
315
|
+
name: manifest?.name ?? undefined,
|
|
316
|
+
version: manifest?.version ?? undefined,
|
|
317
|
+
requiresPython: manifest?.requiresPython ?? undefined,
|
|
318
|
+
layout: manifest?.layout ?? 'flat',
|
|
319
|
+
modules: manifest?.modules ?? [],
|
|
320
|
+
importName: manifest?.importName,
|
|
321
|
+
buildBackend: manifest?.buildBackend ?? undefined,
|
|
322
|
+
entryPoints: manifest?.entryPoints ?? [],
|
|
323
|
+
toolConfiguration: manifest?.toolConfiguration ?? {},
|
|
324
|
+
uvWorkspaceMembers: manifest?.uvWorkspaceMembers ?? [],
|
|
325
|
+
uvSources: manifest?.uvSources ?? [],
|
|
326
|
+
requirementsFiles: manifest?.requirementsFiles ?? [],
|
|
327
|
+
dependencyCounts: {
|
|
328
|
+
runtime: manifest?.dependencies.length ?? 0,
|
|
329
|
+
optional: optionalCounts,
|
|
330
|
+
groups: groupCounts,
|
|
331
|
+
},
|
|
332
|
+
lock: {
|
|
333
|
+
present: lock?.present ?? false,
|
|
334
|
+
path: lock?.path ?? undefined,
|
|
335
|
+
packageCount: lock?.packages.length ?? 0,
|
|
336
|
+
},
|
|
337
|
+
conformance,
|
|
338
|
+
installed:
|
|
339
|
+
installed !== undefined
|
|
340
|
+
? {
|
|
341
|
+
sitePackages: installed.sitePackages,
|
|
342
|
+
count: installed.count,
|
|
343
|
+
editableCount: installed.distributions.filter((entry) => entry.source === 'editable')
|
|
344
|
+
.length,
|
|
345
|
+
}
|
|
346
|
+
: undefined,
|
|
347
|
+
warnings,
|
|
348
|
+
notes,
|
|
349
|
+
suggestions,
|
|
350
|
+
};
|
|
351
|
+
}
|