pi-python-helper 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +85 -125
- package/docs/compatibility.md +17 -1
- package/docs/tools.md +25 -2
- package/extensions/shared.ts +24 -0
- package/extensions/tools/dependencies.ts +0 -120
- package/extensions/tools/environment.ts +30 -5
- package/extensions/tools/testing.ts +80 -8
- package/extensions/tools/validation.ts +427 -44
- package/helpers/scan_project.py +15 -1
- package/package.json +1 -1
- package/skills/python-development/SKILL.md +11 -6
- package/src/build/commands.ts +17 -2
- package/src/build/failure.ts +63 -55
- package/src/build/quality.ts +49 -0
- package/src/build/selection.ts +176 -9
- package/src/build/sync.ts +70 -0
- package/src/build/traceback.ts +55 -0
- package/src/dependencies/aliases.ts +101 -0
- package/src/dependencies/plan.ts +60 -112
- package/src/environment/discovery.ts +30 -3
- package/src/environment/tools.ts +41 -2
- package/src/project/conformance.ts +101 -25
- package/src/project/inspect.ts +126 -53
- package/src/project/paths.ts +15 -0
- package/src/project/pytest-config.ts +45 -0
- package/src/project/root.ts +85 -1
- package/src/project/scanner.ts +61 -9
- package/src/validation/bundle.ts +41 -4
- package/src/validation/tdd.ts +94 -21
package/src/project/root.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, readdir, stat } from 'node:fs/promises';
|
|
2
3
|
import { dirname, join, resolve } from 'node:path';
|
|
3
4
|
|
|
4
5
|
/** Files that mark the root of a uv-managed Python project. */
|
|
@@ -56,6 +57,89 @@ export async function findVenvDir(root: string): Promise<string | undefined> {
|
|
|
56
57
|
return (await isDirectory(candidate)) ? candidate : undefined;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
const TEST_DIRECTORY_NAMES = new Set(['test', 'tests', 'testing']);
|
|
61
|
+
|
|
62
|
+
/** Directories that can never contain the project's own tests. */
|
|
63
|
+
const UNSCANNABLE_DIRECTORIES = new Set([
|
|
64
|
+
'.git',
|
|
65
|
+
'.venv',
|
|
66
|
+
'venv',
|
|
67
|
+
'.tox',
|
|
68
|
+
'.nox',
|
|
69
|
+
'__pycache__',
|
|
70
|
+
'node_modules',
|
|
71
|
+
'build',
|
|
72
|
+
'dist',
|
|
73
|
+
'.eggs',
|
|
74
|
+
'.mypy_cache',
|
|
75
|
+
'.ruff_cache',
|
|
76
|
+
'.pytest_cache',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find the directories that hold tests, relative to the project root.
|
|
81
|
+
*
|
|
82
|
+
* Looking only for `./tests` misses the common layout where the suite lives
|
|
83
|
+
* inside the package it tests (`<package>/tests/`), which made the tool claim a
|
|
84
|
+
* project with hundreds of tests had none.
|
|
85
|
+
*/
|
|
86
|
+
export async function findTestDirectories(root: string, maxDepth = 3): Promise<string[]> {
|
|
87
|
+
const found: string[] = [];
|
|
88
|
+
const walk = async (directory: string, relative: string, depth: number): Promise<void> => {
|
|
89
|
+
let entries;
|
|
90
|
+
try {
|
|
91
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
92
|
+
} catch {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
96
|
+
if (!entry.isDirectory()) continue;
|
|
97
|
+
const name = entry.name;
|
|
98
|
+
if (name.startsWith('.')) continue;
|
|
99
|
+
const childRelative = relative ? `${relative}/${name}` : name;
|
|
100
|
+
if (TEST_DIRECTORY_NAMES.has(name)) {
|
|
101
|
+
found.push(childRelative);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (UNSCANNABLE_DIRECTORIES.has(name)) continue;
|
|
105
|
+
if (depth < maxDepth) await walk(join(directory, name), childRelative, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
await walk(root, '', 1);
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const WINDOWS = process.platform === 'win32';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Locate the interpreter inside a project virtual environment.
|
|
116
|
+
*
|
|
117
|
+
* The interpreter that runs the read-only scanner decides which
|
|
118
|
+
* `site-packages` it can see: a host `python3` maps four modules while the
|
|
119
|
+
* project's own interpreter maps the whole environment, so asking the wrong one
|
|
120
|
+
* makes every import name look like it has no providing distribution.
|
|
121
|
+
*/
|
|
122
|
+
export async function findVenvInterpreter(venvDir: string): Promise<string | undefined> {
|
|
123
|
+
const directories = WINDOWS
|
|
124
|
+
? [join(venvDir, 'Scripts'), join(venvDir, 'bin')]
|
|
125
|
+
: [join(venvDir, 'bin'), join(venvDir, 'Scripts')];
|
|
126
|
+
const names = WINDOWS ? ['python.exe', 'python'] : ['python', 'python3'];
|
|
127
|
+
for (const directory of directories) {
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
const candidate = join(directory, name);
|
|
130
|
+
try {
|
|
131
|
+
const info = await stat(candidate);
|
|
132
|
+
if (!info.isFile()) continue;
|
|
133
|
+
if (!WINDOWS) await access(candidate, constants.X_OK);
|
|
134
|
+
return candidate;
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
59
143
|
export async function isGitIgnored(root: string, entry: string): Promise<boolean | undefined> {
|
|
60
144
|
const gitignore = join(root, '.gitignore');
|
|
61
145
|
try {
|
package/src/project/scanner.ts
CHANGED
|
@@ -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 =
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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),
|
package/src/validation/bundle.ts
CHANGED
|
@@ -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 (
|
|
44
|
-
reason =
|
|
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
|
}
|
package/src/validation/tdd.ts
CHANGED
|
@@ -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
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
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
|
|
21
|
-
const testTokens = tokens(test)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
35
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 &&
|
|
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
|
}
|