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,225 @@
|
|
|
1
|
+
import { open, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Marker files Python seeds into a fresh virtual environment. */
|
|
5
|
+
const BOOTSTRAP_DISTRIBUTIONS = new Set([
|
|
6
|
+
'pip',
|
|
7
|
+
'setuptools',
|
|
8
|
+
'wheel',
|
|
9
|
+
'virtualenv',
|
|
10
|
+
'distribute',
|
|
11
|
+
'distlib',
|
|
12
|
+
'filelock',
|
|
13
|
+
'platformdirs',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Only the header block of `METADATA` is needed, and some distributions ship a
|
|
18
|
+
* multi-megabyte body (full README). Reading a bounded prefix keeps the scan
|
|
19
|
+
* proportional to the number of distributions rather than their size.
|
|
20
|
+
*/
|
|
21
|
+
const METADATA_HEADER_BYTES = 8192;
|
|
22
|
+
const MAX_DISTRIBUTIONS = 5000;
|
|
23
|
+
|
|
24
|
+
export interface InstalledDistribution {
|
|
25
|
+
name: string;
|
|
26
|
+
normalized: string;
|
|
27
|
+
version: string;
|
|
28
|
+
/** Directory name such as `httpx-0.28.1.dist-info`. */
|
|
29
|
+
distInfo: string;
|
|
30
|
+
/** `editable` when the distribution is a live link into the project tree. */
|
|
31
|
+
source: 'editable' | 'copy' | 'unknown';
|
|
32
|
+
/** True for interpreter-seeded packages such as pip, which no lock records. */
|
|
33
|
+
bootstrap: boolean;
|
|
34
|
+
/** Set when the version had to be recovered from the directory name. */
|
|
35
|
+
recoveredFromDirectory?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface InstalledEnvironment {
|
|
39
|
+
sitePackages: string;
|
|
40
|
+
distributions: InstalledDistribution[];
|
|
41
|
+
count: number;
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NORMALIZE_RE = /[-_.]+/g;
|
|
47
|
+
|
|
48
|
+
export function normalizeDistributionName(name: string): string {
|
|
49
|
+
return name.replace(NORMALIZE_RE, '-').trim().toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Locate the `site-packages` directory of a virtual environment. POSIX and
|
|
54
|
+
* Windows layouts differ, and `lib64` is used on some distributions.
|
|
55
|
+
*/
|
|
56
|
+
export async function findSitePackages(venvDir: string): Promise<string | undefined> {
|
|
57
|
+
const isDirectory = async (path: string): Promise<boolean> => {
|
|
58
|
+
try {
|
|
59
|
+
return (await stat(path)).isDirectory();
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const directCandidates = [join(venvDir, 'Lib', 'site-packages')];
|
|
66
|
+
for (const candidate of directCandidates) {
|
|
67
|
+
if (await isDirectory(candidate)) return candidate;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const base of [join(venvDir, 'lib'), join(venvDir, 'lib64')]) {
|
|
71
|
+
let entries;
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
78
|
+
if (!entry.isDirectory()) continue;
|
|
79
|
+
if (entry.name === 'site-packages') return join(base, entry.name);
|
|
80
|
+
const candidate = join(base, entry.name, 'site-packages');
|
|
81
|
+
if (await isDirectory(candidate)) return candidate;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readMetadataHeaders(
|
|
88
|
+
path: string,
|
|
89
|
+
): Promise<{ name?: string; version?: string } | undefined> {
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
handle = await open(path, 'r');
|
|
93
|
+
const buffer = Buffer.alloc(METADATA_HEADER_BYTES);
|
|
94
|
+
const { bytesRead } = await handle.read(buffer, 0, METADATA_HEADER_BYTES, 0);
|
|
95
|
+
const text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
96
|
+
const headers: { name?: string; version?: string } = {};
|
|
97
|
+
for (const line of text.split(/\r?\n/)) {
|
|
98
|
+
if (line.length === 0) break; // the header block ends at the first blank line
|
|
99
|
+
const match = line.match(/^([A-Za-z0-9][A-Za-z0-9-]*):\s*(.*)$/);
|
|
100
|
+
if (!match) continue;
|
|
101
|
+
const key = match[1].toLowerCase();
|
|
102
|
+
if (key === 'name') headers.name = match[2].trim();
|
|
103
|
+
else if (key === 'version') headers.version = match[2].trim();
|
|
104
|
+
if (headers.name && headers.version) break;
|
|
105
|
+
}
|
|
106
|
+
return headers;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
} finally {
|
|
110
|
+
await handle?.close().catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Recover name and version from `<name>-<version>.dist-info`. The version is the
|
|
116
|
+
* final `-`-separated segment that begins with a digit, so names containing
|
|
117
|
+
* hyphens or underscores still parse.
|
|
118
|
+
*/
|
|
119
|
+
export function parseDistInfoDirectory(
|
|
120
|
+
directory: string,
|
|
121
|
+
): { name: string; version: string } | undefined {
|
|
122
|
+
const stem = directory.replace(/\.dist-info$/i, '');
|
|
123
|
+
const match = stem.match(/^(.+?)-(\d[^-]*)$/);
|
|
124
|
+
if (!match) return undefined;
|
|
125
|
+
return { name: match[1], version: match[2] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readDistributionSource(
|
|
129
|
+
distInfoPath: string,
|
|
130
|
+
): Promise<'editable' | 'copy' | 'unknown'> {
|
|
131
|
+
let raw: string;
|
|
132
|
+
try {
|
|
133
|
+
const handle = await open(join(distInfoPath, 'direct_url.json'), 'r');
|
|
134
|
+
try {
|
|
135
|
+
const { size } = await handle.stat();
|
|
136
|
+
const buffer = Buffer.alloc(Math.min(size, 8192));
|
|
137
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
138
|
+
raw = buffer.subarray(0, bytesRead).toString('utf8');
|
|
139
|
+
} finally {
|
|
140
|
+
await handle.close().catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// No direct_url.json: installed from an index, so it is a materialised copy.
|
|
144
|
+
return 'copy';
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(raw) as { dir_info?: { editable?: boolean } };
|
|
148
|
+
return parsed.dir_info?.editable ? 'editable' : 'copy';
|
|
149
|
+
} catch {
|
|
150
|
+
return 'unknown';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read the distributions installed in a virtual environment without running
|
|
156
|
+
* Python. `METADATA` carries the canonical name and version, and the
|
|
157
|
+
* `dist-info` directory name is used as a fallback so a single damaged
|
|
158
|
+
* distribution cannot hide the rest of the environment.
|
|
159
|
+
*/
|
|
160
|
+
export async function readInstalledDistributions(
|
|
161
|
+
venvDir: string,
|
|
162
|
+
options: { maxDistributions?: number } = {},
|
|
163
|
+
): Promise<InstalledEnvironment | undefined> {
|
|
164
|
+
const limit = options.maxDistributions ?? MAX_DISTRIBUTIONS;
|
|
165
|
+
const sitePackages = await findSitePackages(venvDir);
|
|
166
|
+
if (!sitePackages) return undefined;
|
|
167
|
+
|
|
168
|
+
const warnings: string[] = [];
|
|
169
|
+
let entries;
|
|
170
|
+
try {
|
|
171
|
+
entries = await readdir(sitePackages, { withFileTypes: true });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
sitePackages,
|
|
175
|
+
distributions: [],
|
|
176
|
+
count: 0,
|
|
177
|
+
truncated: false,
|
|
178
|
+
warnings: [
|
|
179
|
+
`site-packages could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const distInfos = entries
|
|
185
|
+
.filter((entry) => entry.isDirectory() && entry.name.endsWith('.dist-info'))
|
|
186
|
+
.map((entry) => entry.name)
|
|
187
|
+
.sort();
|
|
188
|
+
|
|
189
|
+
const truncated = distInfos.length > limit;
|
|
190
|
+
const selected = truncated ? distInfos.slice(0, limit) : distInfos;
|
|
191
|
+
if (truncated) {
|
|
192
|
+
warnings.push(`Only the first ${limit} of ${distInfos.length} distributions were inspected.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const distributions: InstalledDistribution[] = [];
|
|
196
|
+
for (const directory of selected) {
|
|
197
|
+
const distInfoPath = join(sitePackages, directory);
|
|
198
|
+
const headers = await readMetadataHeaders(join(distInfoPath, 'METADATA'));
|
|
199
|
+
const fallback = parseDistInfoDirectory(directory);
|
|
200
|
+
const rawName = headers?.name ?? fallback?.name;
|
|
201
|
+
const rawVersion = headers?.version ?? fallback?.version;
|
|
202
|
+
if (!rawName) {
|
|
203
|
+
warnings.push(`${directory} declares no distribution name and was skipped.`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const normalized = normalizeDistributionName(rawName);
|
|
207
|
+
distributions.push({
|
|
208
|
+
name: rawName,
|
|
209
|
+
normalized,
|
|
210
|
+
version: rawVersion ?? '0',
|
|
211
|
+
distInfo: directory,
|
|
212
|
+
source: await readDistributionSource(distInfoPath),
|
|
213
|
+
bootstrap: BOOTSTRAP_DISTRIBUTIONS.has(normalized),
|
|
214
|
+
recoveredFromDirectory: headers?.version === undefined && fallback !== undefined,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
sitePackages,
|
|
220
|
+
distributions,
|
|
221
|
+
count: distributions.length,
|
|
222
|
+
truncated,
|
|
223
|
+
warnings,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function toPosix(path: string): string {
|
|
4
|
+
return path.replace(/\\/g, '/');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isPythonFile(path: string): boolean {
|
|
8
|
+
return toPosix(path).endsWith('.py');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TEST_DIRECTORY = /(^|\/)(tests?|testing)(\/|$)/i;
|
|
12
|
+
const TEST_FILENAME = /(^|\/)(test_[^/]+|[^/]+_test)\.py$/i;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* pytest conventions only: a `tests/` directory, a `test_*.py` / `*_test.py`
|
|
16
|
+
* module, or a conftest. Everything else counts as production code.
|
|
17
|
+
*/
|
|
18
|
+
export function isTestFile(path: string): boolean {
|
|
19
|
+
const posix = toPosix(path);
|
|
20
|
+
if (basename(posix) === 'conftest.py') return true;
|
|
21
|
+
return TEST_DIRECTORY.test(posix) || TEST_FILENAME.test(posix);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isSourceFile(path: string): boolean {
|
|
25
|
+
return isPythonFile(path) && !isTestFile(path);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Directory that contains the file, without a trailing slash. */
|
|
29
|
+
export function parentDir(path: string): string {
|
|
30
|
+
const posix = toPosix(path);
|
|
31
|
+
const index = posix.lastIndexOf('/');
|
|
32
|
+
return index === -1 ? '' : posix.slice(0, index);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Meaningful tokens of a path: directory and file stems, split on case
|
|
37
|
+
* boundaries and punctuation, with generic segments removed.
|
|
38
|
+
*/
|
|
39
|
+
const GENERIC = new Set([
|
|
40
|
+
'src',
|
|
41
|
+
'lib',
|
|
42
|
+
'app',
|
|
43
|
+
'apps',
|
|
44
|
+
'pkg',
|
|
45
|
+
'packages',
|
|
46
|
+
'python',
|
|
47
|
+
'test',
|
|
48
|
+
'tests',
|
|
49
|
+
'testing',
|
|
50
|
+
'unit',
|
|
51
|
+
'integration',
|
|
52
|
+
'py',
|
|
53
|
+
'init',
|
|
54
|
+
'main',
|
|
55
|
+
'demo',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
export function pathTokens(path: string): string[] {
|
|
59
|
+
return toPosix(path)
|
|
60
|
+
.replace(/\.py$/i, '')
|
|
61
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
62
|
+
.split(/[^A-Za-z0-9]+/)
|
|
63
|
+
.map((token) => token.toLowerCase())
|
|
64
|
+
.filter((token) => token.length >= 3 && !GENERIC.has(token));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Importable top-level module a source path belongs to, when discoverable. */
|
|
68
|
+
export function moduleNameFromPath(path: string): string | undefined {
|
|
69
|
+
const segments = toPosix(path).split('/');
|
|
70
|
+
const index = segments.lastIndexOf('src');
|
|
71
|
+
if (index !== -1 && segments.length > index + 2) return segments[index + 1];
|
|
72
|
+
if (segments.length > 1 && !isTestFile(path)) return segments[0];
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { access, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Files that mark the root of a uv-managed Python project. */
|
|
5
|
+
const ROOT_MARKERS = ['pyproject.toml', 'uv.lock', 'setup.py', 'setup.cfg'];
|
|
6
|
+
|
|
7
|
+
export async function exists(path: string): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
await access(path);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function isDirectory(path: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
return (await stat(path)).isDirectory();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function isFile(path: string): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
return (await stat(path)).isFile();
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the project root from a directory. `pyproject.toml` and `uv.lock`
|
|
34
|
+
* win over the legacy markers, so a repository that contains a nested uv
|
|
35
|
+
* project still resolves to that nested project when the search starts inside
|
|
36
|
+
* it.
|
|
37
|
+
*/
|
|
38
|
+
export async function findProjectRoot(start: string): Promise<string | undefined> {
|
|
39
|
+
let current = resolve(start);
|
|
40
|
+
let fallback: string | undefined;
|
|
41
|
+
while (true) {
|
|
42
|
+
if (await isFile(join(current, 'pyproject.toml'))) return current;
|
|
43
|
+
if (await isFile(join(current, 'uv.lock'))) return current;
|
|
44
|
+
for (const marker of ROOT_MARKERS) {
|
|
45
|
+
if (await isFile(join(current, marker))) fallback ??= current;
|
|
46
|
+
}
|
|
47
|
+
const parent = dirname(current);
|
|
48
|
+
if (parent === current) return fallback;
|
|
49
|
+
current = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Locate the virtual environment uv would create for the project. */
|
|
54
|
+
export async function findVenvDir(root: string): Promise<string | undefined> {
|
|
55
|
+
const candidate = join(root, '.venv');
|
|
56
|
+
return (await isDirectory(candidate)) ? candidate : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function isGitIgnored(root: string, entry: string): Promise<boolean | undefined> {
|
|
60
|
+
const gitignore = join(root, '.gitignore');
|
|
61
|
+
try {
|
|
62
|
+
const { readFile } = await import('node:fs/promises');
|
|
63
|
+
const content = await readFile(gitignore, 'utf8');
|
|
64
|
+
return content
|
|
65
|
+
.split(/\r?\n/)
|
|
66
|
+
.map((line) => line.trim())
|
|
67
|
+
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
|
68
|
+
.some((line) => line.replace(/^\//, '').replace(/\/$/, '') === entry);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { runCommand } from '../core/runner.ts';
|
|
2
|
+
|
|
3
|
+
export const HELPER_URL = new URL('../../helpers/scan_project.py', import.meta.url);
|
|
4
|
+
|
|
5
|
+
/** Sections the scanner can produce. A comma-separated combination is allowed. */
|
|
6
|
+
export type ScanMode =
|
|
7
|
+
| 'environment'
|
|
8
|
+
| 'manifest'
|
|
9
|
+
| 'imports'
|
|
10
|
+
| 'all'
|
|
11
|
+
| 'environment,manifest'
|
|
12
|
+
| 'environment,imports'
|
|
13
|
+
| 'manifest,imports';
|
|
14
|
+
|
|
15
|
+
/** Bumped by the scanner when the request or result document changes shape. */
|
|
16
|
+
export const SUPPORTED_SCANNER_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
export interface DeclaredDependency {
|
|
19
|
+
raw: string;
|
|
20
|
+
name: string;
|
|
21
|
+
normalized: string;
|
|
22
|
+
specifier: string;
|
|
23
|
+
extras: string[];
|
|
24
|
+
marker: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ManifestSection {
|
|
28
|
+
pyprojectPath: string | null;
|
|
29
|
+
name: string | null;
|
|
30
|
+
version: string | null;
|
|
31
|
+
requiresPython: string | null;
|
|
32
|
+
description: string | null;
|
|
33
|
+
license: string | null;
|
|
34
|
+
importName?: string;
|
|
35
|
+
dependencies: DeclaredDependency[];
|
|
36
|
+
optionalDependencies: Record<string, DeclaredDependency[]>;
|
|
37
|
+
dependencyGroups: Record<string, DeclaredDependency[]>;
|
|
38
|
+
buildBackend: string | null;
|
|
39
|
+
buildRequires: string[];
|
|
40
|
+
entryPoints: string[];
|
|
41
|
+
toolConfiguration: Record<string, boolean>;
|
|
42
|
+
layout: 'src' | 'flat';
|
|
43
|
+
modules: string[];
|
|
44
|
+
legacySetupPy: boolean;
|
|
45
|
+
legacySetupCfg: boolean;
|
|
46
|
+
requirementsFiles: string[];
|
|
47
|
+
uvWorkspaceMembers: string[];
|
|
48
|
+
uvSources: string[];
|
|
49
|
+
warnings: string[];
|
|
50
|
+
tomlError?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface LockDependencyEdge {
|
|
54
|
+
name: string;
|
|
55
|
+
normalized: string;
|
|
56
|
+
/** Non-null when uv only includes this dependency under a platform/version condition. */
|
|
57
|
+
marker: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LockPackage {
|
|
61
|
+
name: string;
|
|
62
|
+
normalized: string;
|
|
63
|
+
version: string | null;
|
|
64
|
+
source: string | null;
|
|
65
|
+
dependencies?: LockDependencyEdge[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LockSection {
|
|
69
|
+
path: string | null;
|
|
70
|
+
present: boolean;
|
|
71
|
+
version: unknown;
|
|
72
|
+
revision: unknown;
|
|
73
|
+
requiresPython: string | null;
|
|
74
|
+
packages: LockPackage[];
|
|
75
|
+
warnings: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LockComparison {
|
|
79
|
+
specifierCheckAvailable: boolean;
|
|
80
|
+
missingFromLock: string[];
|
|
81
|
+
unsatisfiedInLock: { name: string; specifier: string; locked: string }[];
|
|
82
|
+
requiresPythonMismatch: { manifest: string; lock: string } | null;
|
|
83
|
+
checkedCount: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ImportSection {
|
|
87
|
+
pythonVersion: string;
|
|
88
|
+
stdlibAvailable: boolean;
|
|
89
|
+
layout: 'src' | 'flat';
|
|
90
|
+
localModules: string[];
|
|
91
|
+
files: { path: string; imports: string[]; typeCheckingImports: string[] }[];
|
|
92
|
+
thirdParty: {
|
|
93
|
+
import: string;
|
|
94
|
+
files: string[];
|
|
95
|
+
fileCount: number;
|
|
96
|
+
providers: string[];
|
|
97
|
+
/** True when every importing file guards the import behind TYPE_CHECKING. */
|
|
98
|
+
typeCheckingOnly: boolean;
|
|
99
|
+
typeCheckingFiles: string[];
|
|
100
|
+
}[];
|
|
101
|
+
providersUnavailable: boolean;
|
|
102
|
+
unparsable: { path: string; error: string }[];
|
|
103
|
+
scannedFiles: number;
|
|
104
|
+
truncated: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface EnvironmentSection {
|
|
108
|
+
version: string;
|
|
109
|
+
versionInfo: number[];
|
|
110
|
+
executable: string;
|
|
111
|
+
prefix: string;
|
|
112
|
+
basePrefix: string;
|
|
113
|
+
inVirtualEnvironment: boolean;
|
|
114
|
+
virtualEnv: string | null;
|
|
115
|
+
condaPrefix: string | null;
|
|
116
|
+
candidateVenvDir: string | null;
|
|
117
|
+
implementation: string;
|
|
118
|
+
platform: string;
|
|
119
|
+
stdlibModuleNames: boolean;
|
|
120
|
+
tomlAvailable: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ScanPayload {
|
|
124
|
+
scannerVersion?: number;
|
|
125
|
+
root: string;
|
|
126
|
+
mode: ScanMode;
|
|
127
|
+
pythonVersion: string;
|
|
128
|
+
tomlAvailable: boolean;
|
|
129
|
+
environment?: EnvironmentSection;
|
|
130
|
+
manifest?: ManifestSection;
|
|
131
|
+
lock?: LockSection;
|
|
132
|
+
lockComparison?: LockComparison;
|
|
133
|
+
imports?: ImportSection;
|
|
134
|
+
error?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ScanOutcome {
|
|
138
|
+
ok: boolean;
|
|
139
|
+
interpreter?: string;
|
|
140
|
+
payload?: ScanPayload;
|
|
141
|
+
/** Diagnostic code the caller can surface verbatim when `ok` is false. */
|
|
142
|
+
code?:
|
|
143
|
+
'PYTHON_NOT_FOUND' | 'SCANNER_FAILED' | 'SCANNER_INVALID_OUTPUT' | 'SCANNER_VERSION_MISMATCH';
|
|
144
|
+
message?: string;
|
|
145
|
+
stderr?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let interpreterPromise: Promise<string | undefined> | undefined;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pick the interpreter used for read-only analysis. `python3` is preferred so a
|
|
152
|
+
* `python` that points at a legacy Python 2 install is never selected.
|
|
153
|
+
*/
|
|
154
|
+
export async function resolveInterpreter(
|
|
155
|
+
cwd: string,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
): Promise<string | undefined> {
|
|
158
|
+
interpreterPromise ??= (async () => {
|
|
159
|
+
for (const candidate of ['python3', 'python']) {
|
|
160
|
+
const probe = await runCommand(candidate, ['-c', 'import sys; print(sys.version_info[0])'], {
|
|
161
|
+
cwd,
|
|
162
|
+
signal,
|
|
163
|
+
timeoutMs: 5000,
|
|
164
|
+
maxBytes: 2048,
|
|
165
|
+
});
|
|
166
|
+
if (probe.code === 0 && probe.stdout.trim() === '3') return candidate;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
})();
|
|
170
|
+
return interpreterPromise;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the read-only scanner. A missing Python or an older interpreter without
|
|
175
|
+
* `tomllib` degrades to a structured diagnostic instead of throwing, so static
|
|
176
|
+
* tools remain usable in environments without Python tooling installed.
|
|
177
|
+
*/
|
|
178
|
+
export async function runScanProject(
|
|
179
|
+
cwd: string,
|
|
180
|
+
request: { root: string; mode: ScanMode; maxFiles?: number },
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<ScanOutcome> {
|
|
183
|
+
const interpreter = await resolveInterpreter(cwd, signal);
|
|
184
|
+
if (!interpreter) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
code: 'PYTHON_NOT_FOUND',
|
|
188
|
+
message: 'No Python 3 interpreter was found on PATH.',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const helper = HELPER_URL.pathname;
|
|
192
|
+
const run = await runCommand(interpreter, [helper], {
|
|
193
|
+
cwd,
|
|
194
|
+
signal,
|
|
195
|
+
timeoutMs: 30000,
|
|
196
|
+
maxBytes: 2 * 1024 * 1024,
|
|
197
|
+
stdin: JSON.stringify(request),
|
|
198
|
+
});
|
|
199
|
+
if (run.timedOut) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
interpreter,
|
|
203
|
+
code: 'SCANNER_FAILED',
|
|
204
|
+
message: 'The project scanner timed out.',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (run.code !== 0) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
interpreter,
|
|
211
|
+
code: 'SCANNER_FAILED',
|
|
212
|
+
message: 'The project scanner exited with an error.',
|
|
213
|
+
stderr: run.stderr.trim() || undefined,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const payload = JSON.parse(run.stdout) as ScanPayload;
|
|
218
|
+
if (payload.error) {
|
|
219
|
+
return { ok: false, interpreter, code: 'SCANNER_FAILED', message: payload.error };
|
|
220
|
+
}
|
|
221
|
+
// Refuse to interpret a document whose shape may have changed.
|
|
222
|
+
if (
|
|
223
|
+
typeof payload.scannerVersion === 'number' &&
|
|
224
|
+
payload.scannerVersion !== SUPPORTED_SCANNER_VERSION
|
|
225
|
+
) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
interpreter,
|
|
229
|
+
code: 'SCANNER_VERSION_MISMATCH',
|
|
230
|
+
message: `The scanner reported protocol version ${payload.scannerVersion}, but this extension understands version ${SUPPORTED_SCANNER_VERSION}.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, interpreter, payload };
|
|
234
|
+
} catch {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
interpreter,
|
|
238
|
+
code: 'SCANNER_INVALID_OUTPUT',
|
|
239
|
+
message: 'The project scanner did not return valid JSON.',
|
|
240
|
+
stderr: run.stdout.slice(0, 2000),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ValidationStep {
|
|
2
|
+
executed: boolean;
|
|
3
|
+
ok: boolean;
|
|
4
|
+
exitCode?: number | null;
|
|
5
|
+
failures?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ValidationSummary {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
reason: string;
|
|
11
|
+
checks: {
|
|
12
|
+
lock: boolean;
|
|
13
|
+
sync: boolean;
|
|
14
|
+
test: boolean;
|
|
15
|
+
conformance: boolean;
|
|
16
|
+
staleArtifacts: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The bundle runs `uv lock --check`, then `uv sync --frozen`, then pytest, then
|
|
22
|
+
* verifies that the resulting environment actually matches the lockfile and that
|
|
23
|
+
* no stale coverage report is being quoted.
|
|
24
|
+
*
|
|
25
|
+
* Conformance must be proven, not merely not-failed: a test run that passed
|
|
26
|
+
* against versions the lockfile does not describe is not evidence, so an
|
|
27
|
+
* `unverifiable` verdict fails the gate exactly like drift does.
|
|
28
|
+
*/
|
|
29
|
+
export function summarizeValidation(input: {
|
|
30
|
+
lock: ValidationStep;
|
|
31
|
+
sync: ValidationStep;
|
|
32
|
+
test: ValidationStep;
|
|
33
|
+
conformance: 'consistent' | 'drifted' | 'unverifiable';
|
|
34
|
+
stale: boolean;
|
|
35
|
+
}): ValidationSummary {
|
|
36
|
+
const lock = input.lock.executed && input.lock.ok;
|
|
37
|
+
const sync = input.sync.executed && input.sync.ok;
|
|
38
|
+
const test = input.test.executed && input.test.ok && (input.test.failures ?? 0) === 0;
|
|
39
|
+
const conformance = input.conformance === 'consistent';
|
|
40
|
+
const staleArtifacts = input.stale;
|
|
41
|
+
|
|
42
|
+
let reason = 'Lockfile, environment, tests, and installed versions all agree.';
|
|
43
|
+
if (!input.lock.executed || !input.sync.executed || !input.test.executed) {
|
|
44
|
+
reason = 'Set execute=true to run the validation bundle.';
|
|
45
|
+
} else if (!lock) {
|
|
46
|
+
reason = 'uv.lock is out of date; run uv lock before trusting any test result.';
|
|
47
|
+
} else if (!sync) {
|
|
48
|
+
reason = 'The environment could not be synchronised from the lockfile.';
|
|
49
|
+
} else if (!test) {
|
|
50
|
+
reason = 'Tests failed; inspect the first failing case and its project frame.';
|
|
51
|
+
} else if (input.conformance === 'drifted') {
|
|
52
|
+
reason =
|
|
53
|
+
'Tests passed, but the installed versions do not match uv.lock, so the run does not describe the locked environment.';
|
|
54
|
+
} else if (input.conformance === 'unverifiable') {
|
|
55
|
+
reason =
|
|
56
|
+
'The installed environment could not be compared with uv.lock, so the passing test run is not proven to be on the locked versions.';
|
|
57
|
+
} else if (staleArtifacts) {
|
|
58
|
+
reason = 'Tests passed, but a stale coverage report was detected; refresh it and rerun.';
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
ok: lock && sync && test && conformance && !staleArtifacts,
|
|
62
|
+
reason,
|
|
63
|
+
checks: { lock, sync, test, conformance, staleArtifacts },
|
|
64
|
+
};
|
|
65
|
+
}
|