pi-python-helper 0.3.0 → 0.4.1

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 CHANGED
@@ -7,6 +7,24 @@ does not guarantee a stable public tool schema.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-09-21
11
+
12
+ ### Changed
13
+
14
+ - `src/core/safety.ts` now delegates to `pi-helper-core`'s classifier and supplies only the Python package-manager, environment, and migration rules; the segment-splitting, safe-override precedence, and compound-merge logic is no longer duplicated. Requires `pi-helper-core` 0.1.2, which stops treating `--frozen`/`--locked`/`--list` as read-only flags so `uv sync --frozen` is classified as mutating again.
15
+
16
+ ## [0.4.0] - 2026-09-21
17
+
18
+ ### Changed
19
+
20
+ - Adopt `pi-helper-core` (`^0.1.1`) for the shared response envelope, bounded command runner, TDD checkpoint, validation-bundle gate, completion evidence, artifact staleness, and test selection. `src/core/result.ts` and `src/core/runner.ts` are now thin shims, and `src/validation/` and `src/build/` supply only Python signals, labels, and rules.
21
+ - **Breaking:** tool metadata no longer carries `pythonVersion`; the interpreter version now lives in the ecosystem-neutral `metadata.toolchain` (`{ kind: 'python', version }`).
22
+ - Validation and completion messages now use the shared core wording (`uv lock --check`/`uv sync` labels); the drift check reports a mismatch against "the lockfile" generically.
23
+
24
+ ### Added
25
+
26
+ - `test/core-dependency.test.ts` pins the `pi-helper-core` dependency and the shared behaviours the Python tools delegate to.
27
+
10
28
  ## [0.3.0] - 2026-09-21
11
29
 
12
30
  ### Added
package/docs/tools.md CHANGED
@@ -30,12 +30,13 @@
30
30
  - `cwd`: string
31
31
  - `durationMs`: number
32
32
  - `projectRoot` (optional): string
33
- - `pythonVersion` (optional): string
34
33
  - `toolVersion`: string
34
+ - `toolchain` (optional): object
35
+ - `kind`: string
36
+ - `source`: string
37
+ - `version`: string
35
38
  - `truncated`: boolean
36
39
  - `ok`: boolean
37
- - `projectRoot` (optional): string
38
- - `pythonVersion` (optional): string
39
40
  - `suggestions`: array of
40
41
  - `command` (optional): string
41
42
  - `confidence`: string
@@ -72,7 +72,7 @@ export function registerDependencyTools(pi: Pi): void {
72
72
  errors: [],
73
73
  suggestions: plan.suggestions,
74
74
  projectRoot: root,
75
- pythonVersion: scan.payload.pythonVersion,
75
+ toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
76
76
  }),
77
77
  );
78
78
  } catch (error) {
@@ -67,7 +67,11 @@ export function registerEnvironmentTools(pi: Pi): void {
67
67
  confidence: 'medium' as const,
68
68
  })),
69
69
  projectRoot: environment.projectRoot,
70
- pythonVersion: python?.version,
70
+ toolchain: {
71
+ kind: 'python',
72
+ version: python?.version,
73
+ source: environment.projectRoot ? 'project' : 'path',
74
+ },
71
75
  }),
72
76
  );
73
77
  } catch (error) {
@@ -175,7 +179,7 @@ export function registerEnvironmentTools(pi: Pi): void {
175
179
  errors: [],
176
180
  suggestions: inspection.suggestions,
177
181
  projectRoot: inspection.root,
178
- pythonVersion: scan.payload.pythonVersion,
182
+ toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
179
183
  }),
180
184
  );
181
185
  } catch (error) {
@@ -137,7 +137,7 @@ export function registerTestConfigTools(pi: Pi): void {
137
137
  confidence: 'high' as const,
138
138
  })),
139
139
  projectRoot: root,
140
- pythonVersion: scan.payload.pythonVersion,
140
+ toolchain: { kind: 'python', version: scan.payload.pythonVersion, source: 'project' },
141
141
  }),
142
142
  );
143
143
  } catch (error) {
@@ -590,7 +590,11 @@ export function registerValidationTools(pi: Pi): void {
590
590
  ],
591
591
  commands,
592
592
  projectRoot: root,
593
- pythonVersion: scan.payload?.pythonVersion,
593
+ toolchain: {
594
+ kind: 'python',
595
+ version: scan.payload?.pythonVersion,
596
+ source: 'project',
597
+ },
594
598
  }),
595
599
  );
596
600
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-python-helper",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Python (uv) development tools for the pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -69,5 +69,8 @@
69
69
  "prettier": "^3.9.8",
70
70
  "tsx": "^4.23.13",
71
71
  "typescript": "^7.0.2"
72
+ },
73
+ "dependencies": {
74
+ "pi-helper-core": "^0.1.2"
72
75
  }
73
76
  }
@@ -1,75 +1,26 @@
1
- import { basename, dirname, join } from 'node:path';
1
+ /**
2
+ * Python's test-selection signals over the shared ranking algorithm.
3
+ *
4
+ * The ranking (pytest naming conventions first, fuzzy token overlap second,
5
+ * ubiquitous signals discarded) lives in `pi-helper-core`. This module answers
6
+ * only the Python questions: what is a source/test file, what module a path
7
+ * becomes, and what counts as shared test infrastructure.
8
+ */
9
+ import { selectTests as coreSelectTests, type SelectionSignals } from 'pi-helper-core';
2
10
  import {
3
11
  isPythonFile,
4
12
  isRunnableTestFile,
5
13
  isTestFile,
6
- parentDir,
7
14
  pathTokens,
8
15
  toPosix,
9
16
  } from '../project/paths.ts';
10
17
 
11
- export interface TestSelection {
12
- path: string;
13
- score: number;
14
- reason: string;
15
- }
16
-
17
- export interface SelectionResult {
18
- selected: TestSelection[];
19
- /** True when no changed file could be mapped and every test file is returned. */
20
- fellBackToAll: boolean;
21
- /**
22
- * False when the selection covers every considered test file, so the
23
- * candidate list was not narrowed at all. Reported so a caller does not read
24
- * "30 of 30 selected" as a focused run.
25
- */
26
- narrowed: boolean;
27
- changedSourceFiles: string[];
28
- changedTestFiles: string[];
29
- consideredTestFiles: string[];
30
- /** Files under a test directory that pytest does not collect tests from. */
31
- supportFiles: string[];
32
- /**
33
- * True when at least one candidate was matched by an actual import of a
34
- * changed module, which is the strongest available signal. False means the
35
- * selection rests on naming conventions alone.
36
- */
37
- importEvidenceUsed: boolean;
38
- }
39
-
40
- /**
41
- * Dotted module names imported by each test file, keyed by test path.
42
- * Supplied by the scanner because a test named `test_db_session.py` gives no
43
- * naming hint that it covers `db/database.py`; its imports do.
44
- */
45
- export type TestImportMap = Record<string, string[]>;
46
-
47
- export interface SelectionOptions {
48
- testImports?: TestImportMap;
49
- }
50
-
51
- const SCORE = {
52
- changedTestItself: 100,
53
- /** Importing the changed module is stronger than any name coincidence. */
54
- importsChangedModule: 90,
55
- sameStemSameDir: 80,
56
- sameStem: 60,
57
- sameDirectory: 40,
58
- sharedToken: 20,
59
- sharedModule: 30,
60
- };
61
-
62
- function stem(path: string): string {
63
- return basename(toPosix(path)).replace(/\.py$/i, '');
64
- }
65
-
66
- /** Strip the pytest prefix/suffix so `test_parser.py` and `parser.py` compare equal. */
67
- function normalizedStem(path: string): string {
68
- return stem(path)
69
- .replace(/^test_/, '')
70
- .replace(/_test$/, '')
71
- .toLowerCase();
72
- }
18
+ export type {
19
+ SelectionOptions,
20
+ SelectionResult,
21
+ TestImportMap,
22
+ TestSelection,
23
+ } from 'pi-helper-core';
73
24
 
74
25
  function firstImportableSegment(path: string): string {
75
26
  const segments = toPosix(path)
@@ -106,200 +57,21 @@ export function modulePathsFromFile(path: string): string[] {
106
57
  return [...new Set(candidates)];
107
58
  }
108
59
 
109
- /** True when a test imports the module, or a parent package of it. */
110
- function importsModule(imported: string[], modules: string[]): string | undefined {
111
- let best: string | undefined;
112
- for (const candidate of modules) {
113
- for (const entry of imported) {
114
- const matches =
115
- entry === candidate ||
116
- entry.startsWith(`${candidate}.`) ||
117
- candidate.startsWith(`${entry}.`);
118
- if (!matches) continue;
119
- // Report the most specific import: naming `pkg` when the file actually
120
- // imports `pkg.routes.admin` overstates how broadly the test is coupled.
121
- if (!best || entry.length > best.length) best = entry;
122
- }
123
- }
124
- return best;
125
- }
126
-
127
- /**
128
- * Values that appear in at least this share of the candidates carry no
129
- * information about *which* candidate to run: in a project whose tests all live
130
- * inside the package under test, the package name matches every file.
131
- */
132
- const UBIQUITOUS_SHARE = 0.5;
133
-
134
- function ubiquitousValues(documents: string[][]): Set<string> {
135
- const counts = new Map<string, number>();
136
- for (const values of documents) {
137
- for (const value of new Set(values)) {
138
- if (value.length === 0) continue;
139
- counts.set(value, (counts.get(value) ?? 0) + 1);
140
- }
141
- }
142
- const threshold = Math.max(2, documents.length * UBIQUITOUS_SHARE);
143
- const ubiquitous = new Set<string>();
144
- for (const [value, count] of counts) {
145
- if (count >= threshold) ubiquitous.add(value);
146
- }
147
- return ubiquitous;
148
- }
60
+ const PYTHON_SIGNALS: SelectionSignals = {
61
+ isSourceFile: isPythonFile,
62
+ isTestFile,
63
+ isRunnableTestFile,
64
+ pathTokens,
65
+ moduleNamesForFile: modulePathsFromFile,
66
+ packageName: firstImportableSegment,
67
+ supportFileNames: new Set(['conftest.py']),
68
+ testNameAffixes: { prefixes: ['test_'], suffixes: ['_test'] },
69
+ };
149
70
 
150
- /**
151
- * Rank test files against changed paths using pytest conventions first and
152
- * token overlap second. The convention signals are strong enough in Python that
153
- * a name match should always outrank a fuzzy token match.
154
- *
155
- * Signals shared by every candidate are discarded rather than scored. Without
156
- * that step a package-rooted test tree (`<package>/tests/`) matches its own
157
- * package on every file and the selection degenerates into the full suite.
158
- */
159
71
  export function selectTests(
160
72
  changedPaths: string[],
161
73
  testFiles: string[],
162
- options: SelectionOptions = {},
163
- ): SelectionResult {
164
- const changed = changedPaths.map(toPosix).filter(isPythonFile);
165
- const changedSourceFiles = changed.filter((path) => !isTestFile(path));
166
- const changedTestFiles = changed.filter(isTestFile);
167
- const considered = [...new Set(testFiles.map(toPosix).filter(isPythonFile))].sort();
168
- const supportFiles = considered.filter((path) => !isRunnableTestFile(path));
169
- const testImports = options.testImports ?? {};
170
-
171
- if (!changed.length) {
172
- return {
173
- selected: [],
174
- fellBackToAll: false,
175
- narrowed: false,
176
- changedSourceFiles,
177
- changedTestFiles,
178
- consideredTestFiles: considered,
179
- supportFiles,
180
- importEvidenceUsed: false,
181
- };
182
- }
183
-
184
- const sourceTokens = new Set(changedSourceFiles.flatMap(pathTokens));
185
- const sourceModules = new Set(changedSourceFiles.map(firstImportableSegment));
186
- const sourceStems = new Set(changedSourceFiles.map(normalizedStem));
187
- const sourceModulePaths = changedSourceFiles.map((path) => modulePathsFromFile(path));
188
-
189
- const ubiquitousTokens = ubiquitousValues(considered.map((path) => pathTokens(path)));
190
- const ubiquitousModules = ubiquitousValues(
191
- considered.map((path) => [firstImportableSegment(path)]),
192
- );
193
- // A source directory that contains every test file (the package root) cannot
194
- // distinguish candidates, so it does not score.
195
- const sourceDirs = new Set(
196
- [...new Set(changedSourceFiles.map(parentDir))].filter(
197
- (directory) =>
198
- directory === '' || !considered.every((path) => path.startsWith(`${directory}/`)),
199
- ),
200
- );
201
-
202
- let importEvidenceUsed = false;
203
- const selections: TestSelection[] = [];
204
- for (const testFile of considered) {
205
- // Test infrastructure is not a target, but it can still affect the run, so it
206
- // is reported separately instead of being scored as a test file.
207
- if (!isRunnableTestFile(testFile)) continue;
208
-
209
- const reasons: string[] = [];
210
- let score = 0;
211
-
212
- if (changedTestFiles.includes(testFile)) {
213
- score += SCORE.changedTestItself;
214
- reasons.push('the test file itself changed');
215
- }
216
-
217
- const imported = testImports[testFile];
218
- if (imported && imported.length > 0) {
219
- const matched = sourceModulePaths
220
- .map((modules) => importsModule(imported, modules))
221
- .find((value) => value !== undefined);
222
- if (matched) {
223
- score += SCORE.importsChangedModule;
224
- importEvidenceUsed = true;
225
- reasons.push(`imports the changed module "${matched}"`);
226
- }
227
- }
228
-
229
- const testStem = normalizedStem(testFile);
230
- const testDir = parentDir(testFile);
231
- if (sourceStems.has(testStem)) {
232
- score += SCORE.sameStem;
233
- reasons.push(`module name matches "${testStem}"`);
234
- if (sourceDirs.has(testDir)) {
235
- score += SCORE.sameStemSameDir - SCORE.sameStem;
236
- reasons.push('same directory as the changed module');
237
- }
238
- } else if (sourceDirs.has(testDir)) {
239
- score += SCORE.sameDirectory;
240
- reasons.push('same directory as a changed module');
241
- } else if (sourceDirs.has(dirname(testDir)) || sourceDirs.has(join(dirname(testDir), ''))) {
242
- score += SCORE.sameDirectory - 10;
243
- reasons.push('nested under a changed directory');
244
- }
245
-
246
- const module = firstImportableSegment(testFile);
247
- if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
248
- score += SCORE.sharedModule;
249
- reasons.push(`covers module "${module}"`);
250
- }
251
-
252
- const tokens = pathTokens(testFile);
253
- const shared = tokens.filter(
254
- (token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
255
- );
256
- if (shared.length) {
257
- score += SCORE.sharedToken * Math.min(shared.length, 2);
258
- reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
259
- }
260
-
261
- if (score > 0) {
262
- selections.push({ path: testFile, score, reason: reasons.join('; ') });
263
- }
264
- }
265
-
266
- selections.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
267
-
268
- // A conftest can affect every test, so it is always in scope once tests run.
269
- for (const testFile of considered) {
270
- if (basename(testFile) === 'conftest.py' && !selections.some((s) => s.path === testFile)) {
271
- selections.push({ path: testFile, score: 1, reason: 'shared conftest fixture scope' });
272
- }
273
- }
274
-
275
- const runnableConsidered = considered.filter(isRunnableTestFile);
276
- const selectedRunnable = selections.filter((entry) => isRunnableTestFile(entry.path));
277
- const fellBackToAll = selections.length === 0 && considered.length > 0;
278
- if (fellBackToAll) {
279
- return {
280
- selected: considered.map((path) => ({
281
- path,
282
- score: 0,
283
- reason: 'no match; running the full suite',
284
- })),
285
- fellBackToAll: true,
286
- narrowed: false,
287
- changedSourceFiles,
288
- changedTestFiles,
289
- consideredTestFiles: considered,
290
- supportFiles,
291
- importEvidenceUsed,
292
- };
293
- }
294
-
295
- return {
296
- selected: selections,
297
- fellBackToAll: false,
298
- narrowed: selectedRunnable.length < runnableConsidered.length,
299
- changedSourceFiles,
300
- changedTestFiles,
301
- consideredTestFiles: considered,
302
- supportFiles,
303
- importEvidenceUsed,
304
- };
74
+ options: import('pi-helper-core').SelectionOptions = {},
75
+ ): import('pi-helper-core').SelectionResult {
76
+ return coreSelectTests(changedPaths, testFiles, PYTHON_SIGNALS, options);
305
77
  }
@@ -1,13 +1,18 @@
1
- import { readdir, stat } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ /**
2
+ * Python's staleness spec over the shared `detectStaleArtifacts` walker.
3
+ *
4
+ * Python invalidates bytecode automatically and pytest installs nothing, so a
5
+ * stale coverage report is the one artifact that can make a passing run
6
+ * describe the wrong code. The walk and mtime comparison live in
7
+ * `pi-helper-core`; this module names the sources and artifacts.
8
+ */
9
+ import {
10
+ detectStaleArtifacts as coreDetectStaleArtifacts,
11
+ type StaleArtifact,
12
+ type StalenessSpec,
13
+ } from 'pi-helper-core';
3
14
 
4
- export interface StaleArtifact {
5
- code: 'STALE_COVERAGE_DATA';
6
- message: string;
7
- path: string;
8
- artifactMtimeMs?: number;
9
- newestSource?: { path: string; mtimeMs: number };
10
- }
15
+ export type { StaleArtifact };
11
16
 
12
17
  export interface PythonStalenessReport {
13
18
  stale: boolean;
@@ -16,102 +21,21 @@ export interface PythonStalenessReport {
16
21
  incompleteReason?: string;
17
22
  }
18
23
 
19
- const IGNORED_DIRECTORIES = new Set([
20
- '.git',
21
- '.venv',
22
- 'venv',
23
- '.tox',
24
- '.nox',
25
- '__pycache__',
26
- '.mypy_cache',
27
- '.ruff_cache',
28
- '.pytest_cache',
29
- 'node_modules',
30
- 'build',
31
- 'dist',
32
- '.eggs',
33
- ]);
34
-
35
- const MAX_WALKED_FILES = 5000;
36
-
37
- async function mtimeMs(path: string): Promise<number | undefined> {
38
- try {
39
- return (await stat(path)).mtimeMs;
40
- } catch {
41
- return undefined;
42
- }
43
- }
44
-
45
- async function newestPythonSource(
46
- root: string,
47
- ): Promise<{ path: string; mtimeMs: number } | undefined> {
48
- let newest: { path: string; mtimeMs: number } | undefined;
49
- let visited = 0;
50
- const stack = [root];
51
- while (stack.length > 0) {
52
- const directory = stack.pop() as string;
53
- let entries;
54
- try {
55
- entries = await readdir(directory, { withFileTypes: true });
56
- } catch {
57
- continue;
58
- }
59
- for (const entry of entries) {
60
- if (visited > MAX_WALKED_FILES) return newest;
61
- const path = join(directory, entry.name);
62
- if (entry.isDirectory()) {
63
- if (IGNORED_DIRECTORIES.has(entry.name)) continue;
64
- stack.push(path);
65
- } else if (entry.isFile() && entry.name.endsWith('.py')) {
66
- visited += 1;
67
- const modified = await mtimeMs(path);
68
- if (modified === undefined) continue;
69
- if (!newest || modified > newest.mtimeMs) newest = { path, mtimeMs: modified };
70
- }
71
- }
72
- }
73
- return newest;
74
- }
24
+ const PYTHON_SPEC: StalenessSpec = {
25
+ sourceExtensions: ['.py'],
26
+ artifacts: [
27
+ { name: '.coverage', code: 'STALE_COVERAGE_DATA', describe: 'coverage results' },
28
+ { name: 'coverage.xml', code: 'STALE_COVERAGE_DATA', describe: 'coverage results' },
29
+ ],
30
+ // `.venv`/`venv` are not in the core's universal ignore list because they are
31
+ // Python-specific; caches and build trees already are.
32
+ ignoredDirectories: new Set(['.venv', 'venv']),
33
+ };
75
34
 
76
- /**
77
- * Detect a coverage report that predates the sources it claims to describe.
78
- *
79
- * Python invalidates bytecode automatically and pytest installs nothing, so a
80
- * stale report is the one artifact that can make a passing run describe the
81
- * wrong code. Whether the project itself is installed as a stale copy is a
82
- * structural question and is answered by the environment conformance check
83
- * rather than by comparing mtimes here.
84
- */
85
35
  export async function detectStaleArtifacts(root: string): Promise<PythonStalenessReport> {
86
- const artifacts: StaleArtifact[] = [];
87
- let newestSource: { path: string; mtimeMs: number } | undefined;
88
- try {
89
- newestSource = await newestPythonSource(root);
90
- } catch (error) {
91
- return {
92
- stale: false,
93
- artifacts,
94
- incompleteReason: `Source scan failed: ${error instanceof Error ? error.message : String(error)}`,
95
- };
96
- }
97
- if (!newestSource) {
98
- return { stale: false, artifacts, incompleteReason: 'No Python source files were found.' };
36
+ const report = await coreDetectStaleArtifacts(root, PYTHON_SPEC);
37
+ if (report.incompleteReason?.startsWith('No source file')) {
38
+ return { ...report, incompleteReason: 'No Python source files were found.' };
99
39
  }
100
-
101
- for (const name of ['.coverage', 'coverage.xml']) {
102
- const path = join(root, name);
103
- const modified = await mtimeMs(path);
104
- if (modified === undefined) continue;
105
- if (modified < newestSource.mtimeMs) {
106
- artifacts.push({
107
- code: 'STALE_COVERAGE_DATA',
108
- message: `${name} was written before ${newestSource.path} changed; coverage results do not describe the current sources.`,
109
- path,
110
- artifactMtimeMs: modified,
111
- newestSource,
112
- });
113
- }
114
- }
115
-
116
- return { stale: artifacts.length > 0, artifacts };
40
+ return report;
117
41
  }