pi-python-helper 0.1.1 → 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.
@@ -1,4 +1,5 @@
1
1
  import { runCommand } from '../core/runner.ts';
2
+ import { findVenvDir, findVenvInterpreter } from './root.ts';
2
3
 
3
4
  export const HELPER_URL = new URL('../../helpers/scan_project.py', import.meta.url);
4
5
 
@@ -13,7 +14,7 @@ export type ScanMode =
13
14
  | 'manifest,imports';
14
15
 
15
16
  /** Bumped by the scanner when the request or result document changes shape. */
16
- export const SUPPORTED_SCANNER_VERSION = 1;
17
+ export const SUPPORTED_SCANNER_VERSION = 2;
17
18
 
18
19
  export interface DeclaredDependency {
19
20
  raw: string;
@@ -88,7 +89,16 @@ export interface ImportSection {
88
89
  stdlibAvailable: boolean;
89
90
  layout: 'src' | 'flat';
90
91
  localModules: string[];
91
- files: { path: string; imports: string[]; typeCheckingImports: string[] }[];
92
+ files: {
93
+ path: string;
94
+ imports: string[];
95
+ /**
96
+ * Full dotted module names the file references, so a test file can be
97
+ * matched to the module it imports rather than only by file name.
98
+ */
99
+ importModules?: string[];
100
+ typeCheckingImports: string[];
101
+ }[];
92
102
  thirdParty: {
93
103
  import: string;
94
104
  files: string[];
@@ -137,6 +147,8 @@ export interface ScanPayload {
137
147
  export interface ScanOutcome {
138
148
  ok: boolean;
139
149
  interpreter?: string;
150
+ /** Whether the interpreter came from the project environment or from PATH. */
151
+ interpreterOrigin?: 'venv' | 'path';
140
152
  payload?: ScanPayload;
141
153
  /** Diagnostic code the caller can surface verbatim when `ok` is false. */
142
154
  code?:
@@ -145,17 +157,23 @@ export interface ScanOutcome {
145
157
  stderr?: string;
146
158
  }
147
159
 
148
- let interpreterPromise: Promise<string | undefined> | undefined;
160
+ const interpreterPromises = new Map<string, Promise<string | undefined>>();
149
161
 
150
162
  /**
151
163
  * Pick the interpreter used for read-only analysis. `python3` is preferred so a
152
164
  * `python` that points at a legacy Python 2 install is never selected.
165
+ *
166
+ * Results are cached per key so repeated tool calls in one session do not probe
167
+ * PATH again.
153
168
  */
154
169
  export async function resolveInterpreter(
155
170
  cwd: string,
156
171
  signal?: AbortSignal,
172
+ cacheKey = 'path',
157
173
  ): Promise<string | undefined> {
158
- interpreterPromise ??= (async () => {
174
+ const cached = interpreterPromises.get(cacheKey);
175
+ if (cached) return cached;
176
+ const pending = (async () => {
159
177
  for (const candidate of ['python3', 'python']) {
160
178
  const probe = await runCommand(candidate, ['-c', 'import sys; print(sys.version_info[0])'], {
161
179
  cwd,
@@ -167,7 +185,29 @@ export async function resolveInterpreter(
167
185
  }
168
186
  return undefined;
169
187
  })();
170
- return interpreterPromise;
188
+ interpreterPromises.set(cacheKey, pending);
189
+ return pending;
190
+ }
191
+
192
+ /**
193
+ * Resolve the interpreter whose `site-packages` describe this project.
194
+ *
195
+ * The scanner answers "which distribution provides this import?" by asking the
196
+ * interpreter it runs under. A host `python3` sees only its own site-packages,
197
+ * so every project dependency looks unowned; the project's own interpreter sees
198
+ * the environment that `uv sync` actually built.
199
+ */
200
+ export async function resolveProjectInterpreter(
201
+ root: string,
202
+ cwd: string,
203
+ signal?: AbortSignal,
204
+ ): Promise<{ interpreter?: string; origin: 'venv' | 'path' }> {
205
+ const venvDir = await findVenvDir(root);
206
+ if (venvDir) {
207
+ const venvInterpreter = await findVenvInterpreter(venvDir);
208
+ if (venvInterpreter) return { interpreter: venvInterpreter, origin: 'venv' };
209
+ }
210
+ return { interpreter: await resolveInterpreter(cwd, signal), origin: 'path' };
171
211
  }
172
212
 
173
213
  /**
@@ -180,12 +220,14 @@ export async function runScanProject(
180
220
  request: { root: string; mode: ScanMode; maxFiles?: number },
181
221
  signal?: AbortSignal,
182
222
  ): Promise<ScanOutcome> {
183
- const interpreter = await resolveInterpreter(cwd, signal);
223
+ const resolved = await resolveProjectInterpreter(request.root, cwd, signal);
224
+ const interpreter = resolved.interpreter;
225
+ const origin = resolved.origin;
184
226
  if (!interpreter) {
185
227
  return {
186
228
  ok: false,
187
229
  code: 'PYTHON_NOT_FOUND',
188
- message: 'No Python 3 interpreter was found on PATH.',
230
+ message: 'No Python 3 interpreter was found in the project environment or on PATH.',
189
231
  };
190
232
  }
191
233
  const helper = HELPER_URL.pathname;
@@ -200,6 +242,7 @@ export async function runScanProject(
200
242
  return {
201
243
  ok: false,
202
244
  interpreter,
245
+ interpreterOrigin: origin,
203
246
  code: 'SCANNER_FAILED',
204
247
  message: 'The project scanner timed out.',
205
248
  };
@@ -208,6 +251,7 @@ export async function runScanProject(
208
251
  return {
209
252
  ok: false,
210
253
  interpreter,
254
+ interpreterOrigin: origin,
211
255
  code: 'SCANNER_FAILED',
212
256
  message: 'The project scanner exited with an error.',
213
257
  stderr: run.stderr.trim() || undefined,
@@ -216,7 +260,13 @@ export async function runScanProject(
216
260
  try {
217
261
  const payload = JSON.parse(run.stdout) as ScanPayload;
218
262
  if (payload.error) {
219
- return { ok: false, interpreter, code: 'SCANNER_FAILED', message: payload.error };
263
+ return {
264
+ ok: false,
265
+ interpreter,
266
+ interpreterOrigin: origin,
267
+ code: 'SCANNER_FAILED',
268
+ message: payload.error,
269
+ };
220
270
  }
221
271
  // Refuse to interpret a document whose shape may have changed.
222
272
  if (
@@ -226,15 +276,17 @@ export async function runScanProject(
226
276
  return {
227
277
  ok: false,
228
278
  interpreter,
279
+ interpreterOrigin: origin,
229
280
  code: 'SCANNER_VERSION_MISMATCH',
230
281
  message: `The scanner reported protocol version ${payload.scannerVersion}, but this extension understands version ${SUPPORTED_SCANNER_VERSION}.`,
231
282
  };
232
283
  }
233
- return { ok: true, interpreter, payload };
284
+ return { ok: true, interpreter, interpreterOrigin: origin, payload };
234
285
  } catch {
235
286
  return {
236
287
  ok: false,
237
288
  interpreter,
289
+ interpreterOrigin: origin,
238
290
  code: 'SCANNER_INVALID_OUTPUT',
239
291
  message: 'The project scanner did not return valid JSON.',
240
292
  stderr: run.stdout.slice(0, 2000),
@@ -1,8 +1,16 @@
1
1
  export interface ValidationStep {
2
+ /** Step label, used for the quality checks (`ruff`, `pyright`). */
3
+ name?: string;
2
4
  executed: boolean;
3
5
  ok: boolean;
4
6
  exitCode?: number | null;
5
7
  failures?: number;
8
+ /**
9
+ * Why a step was not executed. A step that was skipped deliberately (the
10
+ * environment is missing the tool it needs) must say so, otherwise the run
11
+ * looks like an unexplained test failure.
12
+ */
13
+ skippedReason?: string;
6
14
  }
7
15
 
8
16
  export interface ValidationSummary {
@@ -13,10 +21,18 @@ export interface ValidationSummary {
13
21
  sync: boolean;
14
22
  test: boolean;
15
23
  conformance: boolean;
24
+ /**
25
+ * True when every configured quality command passed. Vacuously true when the
26
+ * project declares none, so a project without lint/type tooling is not
27
+ * penalised.
28
+ */
29
+ quality: boolean;
16
30
  staleArtifacts: boolean;
17
31
  };
18
32
  }
19
33
 
34
+ const PREVIEW_REASON = 'Set execute=true to run the validation bundle.';
35
+
20
36
  /**
21
37
  * The bundle runs `uv lock --check`, then `uv sync --frozen`, then pytest, then
22
38
  * verifies that the resulting environment actually matches the lockfile and that
@@ -25,27 +41,45 @@ export interface ValidationSummary {
25
41
  * Conformance must be proven, not merely not-failed: a test run that passed
26
42
  * against versions the lockfile does not describe is not evidence, so an
27
43
  * `unverifiable` verdict fails the gate exactly like drift does.
44
+ *
45
+ * A test step that was never executed is also not evidence, and when the reason
46
+ * is known (the environment no longer provides pytest) that reason is reported
47
+ * instead of a generic failure.
28
48
  */
29
49
  export function summarizeValidation(input: {
30
50
  lock: ValidationStep;
31
51
  sync: ValidationStep;
32
52
  test: ValidationStep;
53
+ /** Declared lint/type commands; omitted when the project declares none. */
54
+ quality?: ValidationStep[];
33
55
  conformance: 'consistent' | 'drifted' | 'unverifiable';
34
56
  stale: boolean;
57
+ /** True when nothing was executed because the caller only asked for a preview. */
58
+ preview?: boolean;
35
59
  }): ValidationSummary {
36
60
  const lock = input.lock.executed && input.lock.ok;
37
61
  const sync = input.sync.executed && input.sync.ok;
38
62
  const test = input.test.executed && input.test.ok && (input.test.failures ?? 0) === 0;
63
+ const qualitySteps = input.quality ?? [];
64
+ const quality = qualitySteps.every((step) => step.executed && step.ok);
65
+ const failedQuality = qualitySteps.filter((step) => !step.executed || !step.ok);
39
66
  const conformance = input.conformance === 'consistent';
40
67
  const staleArtifacts = input.stale;
41
68
 
42
69
  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.';
70
+ if (input.preview) {
71
+ reason = PREVIEW_REASON;
72
+ } else if (!input.lock.executed || !input.sync.executed) {
73
+ reason = !input.lock.executed
74
+ ? 'uv lock --check was not executed, so lockfile agreement is unproven.'
75
+ : 'uv sync was not executed, so the environment the tests ran in is unknown.';
45
76
  } else if (!lock) {
46
77
  reason = 'uv.lock is out of date; run uv lock before trusting any test result.';
47
78
  } else if (!sync) {
48
79
  reason = 'The environment could not be synchronised from the lockfile.';
80
+ } else if (!input.test.executed) {
81
+ reason =
82
+ input.test.skippedReason ?? 'Tests were not executed, so no test result exists to report.';
49
83
  } else if (!test) {
50
84
  reason = 'Tests failed; inspect the first failing case and its project frame.';
51
85
  } else if (input.conformance === 'drifted') {
@@ -54,12 +88,15 @@ export function summarizeValidation(input: {
54
88
  } else if (input.conformance === 'unverifiable') {
55
89
  reason =
56
90
  'The installed environment could not be compared with uv.lock, so the passing test run is not proven to be on the locked versions.';
91
+ } else if (failedQuality.length > 0) {
92
+ const names = failedQuality.map((step) => step.name ?? 'quality check').join(', ');
93
+ reason = `Tests passed, but the declared quality check(s) failed: ${names}.`;
57
94
  } else if (staleArtifacts) {
58
95
  reason = 'Tests passed, but a stale coverage report was detected; refresh it and rerun.';
59
96
  }
60
97
  return {
61
- ok: lock && sync && test && conformance && !staleArtifacts,
98
+ ok: lock && sync && test && conformance && quality && !staleArtifacts,
62
99
  reason,
63
- checks: { lock, sync, test, conformance, staleArtifacts },
100
+ checks: { lock, sync, test, conformance, quality, staleArtifacts },
64
101
  };
65
102
  }
@@ -1,38 +1,91 @@
1
1
  import { isTestFile } from '../project/paths.ts';
2
2
 
3
+ /** A production file and a test file that were matched to each other. */
4
+ export interface TddAssociation {
5
+ source: string;
6
+ test: string;
7
+ /** Tokens both paths share, so the caller can judge the match. */
8
+ sharedTokens: string[];
9
+ /**
10
+ * `module` when the match goes beyond the path prefix every file shares,
11
+ * `package` when only the common package prefix matched. A package-level match
12
+ * still passes the checkpoint, but it is weak evidence and is disclosed.
13
+ */
14
+ strength: 'module' | 'package';
15
+ }
16
+
3
17
  export interface TddCheckpoint {
4
18
  ok: boolean;
5
19
  reasons: string[];
6
20
  sourceChanges: string[];
7
21
  testChanges: string[];
22
+ associations: TddAssociation[];
23
+ /** True when a match rested only on the shared package prefix. */
24
+ weakAssociation: boolean;
8
25
  }
9
26
 
27
+ /** Tokens shorter than this cannot distinguish two module names. */
10
28
  const MIN_TOKEN_LENGTH = 4;
11
29
 
30
+ /** Tokens that appear in the prefix of every file and so carry no meaning. */
31
+ const PREFIX_TOKENS = new Set(['test', 'tests', 'testing', 'src', 'lib']);
32
+
12
33
  function tokens(path: string): string[] {
13
34
  return path
14
- .toLowerCase()
15
- .replace(/\.py$/, '')
16
- .split(/[^a-z0-9]+/)
17
- .filter((token) => token.length >= MIN_TOKEN_LENGTH);
35
+ .replace(/\.py$/i, '')
36
+ .split(/[^A-Za-z0-9]+/)
37
+ .map((token) => token.toLowerCase())
38
+ .filter((token) => token.length >= MIN_TOKEN_LENGTH && !PREFIX_TOKENS.has(token));
18
39
  }
19
40
 
20
- function related(source: string, test: string): boolean {
21
- const testTokens = tokens(test).filter((token) => token !== 'test' && token !== 'tests');
22
- return tokens(source).some((sourceToken) =>
23
- testTokens.some(
24
- (testToken) =>
25
- sourceToken === testToken ||
26
- sourceToken.startsWith(testToken) ||
27
- testToken.startsWith(sourceToken),
28
- ),
29
- );
41
+ function sharedTokens(source: string, test: string): string[] {
42
+ const testTokens = tokens(test);
43
+ const shared: string[] = [];
44
+ for (const sourceToken of new Set(tokens(source))) {
45
+ if (
46
+ testTokens.some(
47
+ (testToken) =>
48
+ sourceToken === testToken ||
49
+ sourceToken.startsWith(testToken) ||
50
+ testToken.startsWith(sourceToken),
51
+ )
52
+ ) {
53
+ shared.push(sourceToken);
54
+ }
55
+ }
56
+ return shared;
57
+ }
58
+
59
+ /**
60
+ * Tokens contributed by the directory prefix every changed path shares.
61
+ *
62
+ * In a project whose tests live inside the package under test, every path starts
63
+ * with the package name, so those tokens say nothing about whether a specific
64
+ * test covers a specific module. A common prefix of nothing (no shared
65
+ * directory) yields no exclusions, so an exact name match is never downgraded.
66
+ */
67
+ function commonPrefixTokens(paths: string[]): Set<string> {
68
+ if (paths.length < 2) return new Set();
69
+ const directories = paths.map((path) => path.replace(/\\/g, '/').split('/').slice(0, -1));
70
+ const [first, ...rest] = directories;
71
+ const common: string[] = [];
72
+ for (let index = 0; index < first.length; index += 1) {
73
+ const segment = first[index];
74
+ if (rest.every((entry) => entry[index] === segment)) common.push(segment);
75
+ else break;
76
+ }
77
+ return new Set(common.flatMap((segment) => tokens(segment)));
30
78
  }
31
79
 
32
80
  /**
33
81
  * Check that production changes are accompanied by a plausibly related test
34
- * change. Matching is name-based on purpose: it is cheap, deterministic, and
35
- * only used to decide whether to run the heavier verification bundle.
82
+ * change.
83
+ *
84
+ * Matching is name-based on purpose: it is cheap, deterministic, and only used
85
+ * to decide whether to run the heavier verification bundle. Because it is only
86
+ * name-based, it also reports *why* each pair matched and downgrades a match
87
+ * that rests solely on the package prefix instead of silently counting it as
88
+ * strong evidence.
36
89
  */
37
90
  export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []): TddCheckpoint {
38
91
  const all = [...new Set([...changedPaths, ...testChangedPaths])].map((path) =>
@@ -41,15 +94,33 @@ export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []
41
94
  const sourceChanges = all.filter((path) => path.endsWith('.py') && !isTestFile(path));
42
95
  const testChanges = all.filter((path) => path.endsWith('.py') && isTestFile(path));
43
96
 
44
- const hasRelatedTest =
45
- testChanges.length > 0
46
- ? sourceChanges.some((source) => testChanges.some((test) => related(source, test)))
47
- : false;
97
+ // `fastapi_server/db/database.py` and `fastapi_server/tests/unit/test_db.py`
98
+ // share `fastapi` and `server` with every other file in the project, so those
99
+ // tokens must not be treated as evidence of a real relationship.
100
+ const ubiquitous = commonPrefixTokens([...sourceChanges, ...testChanges]);
101
+
102
+ const associations: TddAssociation[] = [];
103
+ for (const source of sourceChanges) {
104
+ for (const test of testChanges) {
105
+ const shared = sharedTokens(source, test);
106
+ if (shared.length === 0) continue;
107
+ const discriminating = shared.filter((token) => !ubiquitous.has(token));
108
+ associations.push({
109
+ source,
110
+ test,
111
+ sharedTokens: shared,
112
+ strength: discriminating.length > 0 ? 'module' : 'package',
113
+ });
114
+ }
115
+ }
116
+
117
+ const strong = associations.some((entry) => entry.strength === 'module');
118
+ const weakAssociation = associations.length > 0 && !strong;
48
119
 
49
120
  const reasons: string[] = [];
50
121
  if (sourceChanges.length > 0 && testChanges.length === 0) {
51
122
  reasons.push('Production Python files changed without any test file change.');
52
- } else if (sourceChanges.length > 0 && !hasRelatedTest) {
123
+ } else if (sourceChanges.length > 0 && associations.length === 0) {
53
124
  reasons.push('Changed test files do not appear related to the changed production modules.');
54
125
  }
55
126
 
@@ -58,5 +129,7 @@ export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []
58
129
  reasons,
59
130
  sourceChanges,
60
131
  testChanges,
132
+ associations,
133
+ weakAssociation,
61
134
  };
62
135
  }