pi-python-helper 0.3.0 → 0.4.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 +12 -0
- package/docs/tools.md +4 -3
- package/extensions/tools/dependencies.ts +1 -1
- package/extensions/tools/environment.ts +6 -2
- package/extensions/tools/test-config.ts +1 -1
- package/extensions/tools/validation.ts +5 -1
- package/package.json +4 -1
- package/src/build/selection.ts +28 -256
- package/src/build/staleness.ts +28 -104
- package/src/core/result.ts +23 -123
- package/src/core/runner.ts +3 -94
- package/src/validation/bundle.ts +40 -75
- package/src/validation/evidence.ts +25 -21
- package/src/validation/tdd.ts +21 -130
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ does not guarantee a stable public tool schema.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- **Breaking:** tool metadata no longer carries `pythonVersion`; the interpreter version now lives in the ecosystem-neutral `metadata.toolchain` (`{ kind: 'python', version }`).
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `test/core-dependency.test.ts` pins the `pi-helper-core` dependency and the shared behaviours the Python tools delegate to.
|
|
21
|
+
|
|
10
22
|
## [0.3.0] - 2026-09-21
|
|
11
23
|
|
|
12
24
|
### 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"version": "0.4.0",
|
|
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.1"
|
|
72
75
|
}
|
|
73
76
|
}
|
package/src/build/selection.ts
CHANGED
|
@@ -1,75 +1,26 @@
|
|
|
1
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/build/staleness.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
|
20
|
-
'.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'
|
|
26
|
-
|
|
27
|
-
'.
|
|
28
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|
package/src/core/result.ts
CHANGED
|
@@ -1,129 +1,29 @@
|
|
|
1
|
-
import { TOOL_VERSION } from './version.ts';
|
|
2
|
-
|
|
3
|
-
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
|
|
4
|
-
|
|
5
|
-
export interface Diagnostic {
|
|
6
|
-
code?: string;
|
|
7
|
-
message: string;
|
|
8
|
-
severity: DiagnosticSeverity;
|
|
9
|
-
path?: string;
|
|
10
|
-
line?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface Evidence {
|
|
14
|
-
kind: string;
|
|
15
|
-
message?: string;
|
|
16
|
-
[key: string]: unknown;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface Suggestion {
|
|
20
|
-
message: string;
|
|
21
|
-
confidence?: 'low' | 'medium' | 'high';
|
|
22
|
-
command?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface CommandPreview {
|
|
26
|
-
executable: string;
|
|
27
|
-
args: string[];
|
|
28
|
-
cwd?: string;
|
|
29
|
-
/** Risk classification for the command; `read` commands never change project state. */
|
|
30
|
-
risk?: 'read' | 'mutating' | 'irreversible';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface ToolMetadata {
|
|
34
|
-
toolVersion: string;
|
|
35
|
-
cwd: string;
|
|
36
|
-
durationMs: number;
|
|
37
|
-
truncated: boolean;
|
|
38
|
-
projectRoot?: string;
|
|
39
|
-
pythonVersion?: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
1
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `
|
|
2
|
+
* Local shim over the shared `pi-helper-core` envelope.
|
|
3
|
+
*
|
|
4
|
+
* Every tool imports `result`/`failure` and the shared types from here, so the
|
|
5
|
+
* envelope is defined in exactly one place (`pi-helper-core`) while call sites
|
|
6
|
+
* stay unaware of that. `pythonVersion` moved into the ecosystem-neutral
|
|
7
|
+
* `metadata.toolchain` field.
|
|
46
8
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
* The tool's own verdict, not "the tool ran". `true` means the question this
|
|
50
|
-
* tool asks was answered affirmatively: the project state is acceptable, the
|
|
51
|
-
* command succeeded, or the gate may proceed. A diagnostic tool that finds a
|
|
52
|
-
* problem therefore returns `ok: false` without the tool itself having
|
|
53
|
-
* failed. Read `attention` for "must the caller act".
|
|
54
|
-
*/
|
|
55
|
-
ok: boolean;
|
|
56
|
-
/**
|
|
57
|
-
* `true` when the caller must act before proceeding: the tool failed, or it
|
|
58
|
-
* emitted a warning or an error. An `info` diagnostic is informational by
|
|
59
|
-
* definition and does not set this. Derived from `ok`, `warnings`, and
|
|
60
|
-
* `errors` unless a tool sets it explicitly, so `ok: false` always implies
|
|
61
|
-
* `attention: true` and no diagnostic is silently dropped. This is the field
|
|
62
|
-
* to read when the question is "do I need to do something".
|
|
63
|
-
*/
|
|
64
|
-
attention: boolean;
|
|
65
|
-
summary: string;
|
|
66
|
-
data?: T;
|
|
67
|
-
evidence: Evidence[];
|
|
68
|
-
warnings: Diagnostic[];
|
|
69
|
-
errors: Diagnostic[];
|
|
70
|
-
suggestions: Suggestion[];
|
|
71
|
-
commands?: CommandPreview[];
|
|
72
|
-
metadata: ToolMetadata;
|
|
73
|
-
}
|
|
9
|
+
import { createResultFactory } from 'pi-helper-core';
|
|
10
|
+
import { TOOL_VERSION } from './version.ts';
|
|
74
11
|
|
|
75
|
-
|
|
76
|
-
* An `info` diagnostic records a fact; only a warning or an error asks the
|
|
77
|
-
* caller to do something. Keeping them apart stops a purely informational note
|
|
78
|
-
* from raising `attention`.
|
|
79
|
-
*/
|
|
80
|
-
function isActionable(value: { warnings: Diagnostic[]; errors: Diagnostic[] }): boolean {
|
|
81
|
-
return [...value.warnings, ...value.errors].some((entry) => entry.severity !== 'info');
|
|
82
|
-
}
|
|
12
|
+
export const { result, failure } = createResultFactory(TOOL_VERSION);
|
|
83
13
|
|
|
84
|
-
export
|
|
85
|
-
cwd: string,
|
|
86
|
-
startedAt: number,
|
|
87
|
-
value: Omit<PyToolResult<T>, 'metadata' | 'attention'> & {
|
|
88
|
-
attention?: boolean;
|
|
89
|
-
truncated?: boolean;
|
|
90
|
-
projectRoot?: string;
|
|
91
|
-
pythonVersion?: string;
|
|
92
|
-
},
|
|
93
|
-
): PyToolResult<T> {
|
|
94
|
-
return {
|
|
95
|
-
...value,
|
|
96
|
-
attention: value.attention ?? (!value.ok || isActionable(value)),
|
|
97
|
-
metadata: {
|
|
98
|
-
toolVersion: TOOL_VERSION,
|
|
99
|
-
cwd,
|
|
100
|
-
durationMs: Date.now() - startedAt,
|
|
101
|
-
truncated: value.truncated ?? false,
|
|
102
|
-
projectRoot: value.projectRoot,
|
|
103
|
-
pythonVersion: value.pythonVersion,
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
}
|
|
14
|
+
export { CORE_SCHEMA_VERSION, isActionable, note, warn } from 'pi-helper-core';
|
|
107
15
|
|
|
108
|
-
export
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
evidence: [],
|
|
119
|
-
warnings: [],
|
|
120
|
-
errors: [{ code, message, severity: 'error' }],
|
|
121
|
-
suggestions: [],
|
|
122
|
-
...details,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
16
|
+
export type {
|
|
17
|
+
CommandPreview,
|
|
18
|
+
CommandRisk,
|
|
19
|
+
Diagnostic,
|
|
20
|
+
Evidence,
|
|
21
|
+
Severity,
|
|
22
|
+
Suggestion,
|
|
23
|
+
ToolchainInfo,
|
|
24
|
+
ToolResult,
|
|
25
|
+
} from 'pi-helper-core';
|
|
125
26
|
|
|
126
|
-
/**
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
}
|
|
27
|
+
/** The Python tools' view of the shared envelope. */
|
|
28
|
+
export type PyToolResult<T = unknown> = import('pi-helper-core').ToolResult<T>;
|
|
29
|
+
export type DiagnosticSeverity = import('pi-helper-core').Severity;
|
package/src/core/runner.ts
CHANGED
|
@@ -1,96 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
export interface RunOptions {
|
|
4
|
-
cwd: string;
|
|
5
|
-
env?: NodeJS.ProcessEnv;
|
|
6
|
-
timeoutMs?: number;
|
|
7
|
-
signal?: AbortSignal;
|
|
8
|
-
maxBytes?: number;
|
|
9
|
-
stdin?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface RunResult {
|
|
13
|
-
code: number | null;
|
|
14
|
-
stdout: string;
|
|
15
|
-
stderr: string;
|
|
16
|
-
timedOut: boolean;
|
|
17
|
-
cancelled: boolean;
|
|
18
|
-
truncated: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function appendLimited(current: string, chunk: string, maxBytes: number): [string, boolean] {
|
|
22
|
-
const next = current + chunk;
|
|
23
|
-
if (Buffer.byteLength(next, 'utf8') <= maxBytes) return [next, false];
|
|
24
|
-
return [Buffer.from(next, 'utf8').subarray(0, maxBytes).toString('utf8'), true];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
1
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* and every call must stay inside a timeout and an output cap.
|
|
2
|
+
* The bounded subprocess runner now lives in `pi-helper-core`; this module
|
|
3
|
+
* keeps the local import path so no call site has to change.
|
|
31
4
|
*/
|
|
32
|
-
export
|
|
33
|
-
executable: string,
|
|
34
|
-
args: string[],
|
|
35
|
-
options: RunOptions,
|
|
36
|
-
): Promise<RunResult> {
|
|
37
|
-
const maxBytes = options.maxBytes ?? 100 * 1024;
|
|
38
|
-
const child = spawn(executable, args, {
|
|
39
|
-
cwd: options.cwd,
|
|
40
|
-
env: { ...process.env, ...options.env },
|
|
41
|
-
detached: process.platform !== 'win32',
|
|
42
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
if (options.stdin !== undefined) child.stdin.write(options.stdin);
|
|
46
|
-
child.stdin.end();
|
|
47
|
-
|
|
48
|
-
let stdout = '';
|
|
49
|
-
let stderr = '';
|
|
50
|
-
let truncated = false;
|
|
51
|
-
let timedOut = false;
|
|
52
|
-
let cancelled = false;
|
|
53
|
-
let spawnError: Error | undefined;
|
|
54
|
-
const onAbort = () => {
|
|
55
|
-
cancelled = true;
|
|
56
|
-
terminate(child);
|
|
57
|
-
};
|
|
58
|
-
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
59
|
-
const timeout = options.timeoutMs
|
|
60
|
-
? setTimeout(() => {
|
|
61
|
-
timedOut = true;
|
|
62
|
-
terminate(child);
|
|
63
|
-
}, options.timeoutMs)
|
|
64
|
-
: undefined;
|
|
65
|
-
|
|
66
|
-
child.on('error', (error) => {
|
|
67
|
-
spawnError = error;
|
|
68
|
-
});
|
|
69
|
-
child.stdout.on('data', (chunk: Buffer) => {
|
|
70
|
-
const [next, wasTruncated] = appendLimited(stdout, chunk.toString(), maxBytes);
|
|
71
|
-
stdout = next;
|
|
72
|
-
truncated ||= wasTruncated;
|
|
73
|
-
});
|
|
74
|
-
child.stderr.on('data', (chunk: Buffer) => {
|
|
75
|
-
const [next, wasTruncated] = appendLimited(stderr, chunk.toString(), maxBytes);
|
|
76
|
-
stderr = next;
|
|
77
|
-
truncated ||= wasTruncated;
|
|
78
|
-
});
|
|
79
|
-
const [code] = await new Promise<[number | null]>((resolve) => {
|
|
80
|
-
child.once('close', (exitCode) => resolve([exitCode]));
|
|
81
|
-
});
|
|
82
|
-
if (timeout) clearTimeout(timeout);
|
|
83
|
-
options.signal?.removeEventListener('abort', onAbort);
|
|
84
|
-
if (spawnError) stderr = `${stderr}${stderr ? '\n' : ''}${spawnError.message}`;
|
|
85
|
-
return { code, stdout, stderr, timedOut, cancelled, truncated };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function terminate(child: ReturnType<typeof spawn>): void {
|
|
89
|
-
if (child.pid === undefined) return;
|
|
90
|
-
try {
|
|
91
|
-
if (process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');
|
|
92
|
-
else child.kill('SIGTERM');
|
|
93
|
-
} catch {
|
|
94
|
-
child.kill('SIGTERM');
|
|
95
|
-
}
|
|
96
|
-
}
|
|
5
|
+
export { isSpawnFailure, runCommand, type RunOptions, type RunResult } from 'pi-helper-core';
|
package/src/validation/bundle.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Python's view of the shared validation-bundle gate.
|
|
3
|
+
*
|
|
4
|
+
* `pi-helper-core` owns the lock → preparation → test → quality → conformance
|
|
5
|
+
* sequence; this module names the Python steps (`uv lock --check`, `uv sync`)
|
|
6
|
+
* and preserves the local `sync` field name in the returned `checks` map.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
summarizeValidation as coreSummarizeValidation,
|
|
10
|
+
type ValidationStep,
|
|
11
|
+
} from 'pi-helper-core';
|
|
12
|
+
|
|
13
|
+
export type { ValidationStep };
|
|
15
14
|
|
|
16
15
|
export interface ValidationSummary {
|
|
17
16
|
ok: boolean;
|
|
@@ -21,32 +20,12 @@ export interface ValidationSummary {
|
|
|
21
20
|
sync: boolean;
|
|
22
21
|
test: boolean;
|
|
23
22
|
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
23
|
quality: boolean;
|
|
30
24
|
staleArtifacts: boolean;
|
|
31
25
|
};
|
|
32
26
|
}
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The bundle runs `uv lock --check`, then `uv sync --frozen`, then pytest, then
|
|
38
|
-
* verifies that the resulting environment actually matches the lockfile and that
|
|
39
|
-
* no stale coverage report is being quoted.
|
|
40
|
-
*
|
|
41
|
-
* Conformance must be proven, not merely not-failed: a test run that passed
|
|
42
|
-
* against versions the lockfile does not describe is not evidence, so an
|
|
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.
|
|
48
|
-
*/
|
|
49
|
-
export function summarizeValidation(input: {
|
|
28
|
+
export interface ValidationInput {
|
|
50
29
|
lock: ValidationStep;
|
|
51
30
|
sync: ValidationStep;
|
|
52
31
|
test: ValidationStep;
|
|
@@ -56,47 +35,33 @@ export function summarizeValidation(input: {
|
|
|
56
35
|
stale: boolean;
|
|
57
36
|
/** True when nothing was executed because the caller only asked for a preview. */
|
|
58
37
|
preview?: boolean;
|
|
59
|
-
}
|
|
60
|
-
const lock = input.lock.executed && input.lock.ok;
|
|
61
|
-
const sync = input.sync.executed && input.sync.ok;
|
|
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);
|
|
66
|
-
const conformance = input.conformance === 'consistent';
|
|
67
|
-
const staleArtifacts = input.stale;
|
|
38
|
+
}
|
|
68
39
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
reason = 'Tests failed; inspect the first failing case and its project frame.';
|
|
85
|
-
} else if (input.conformance === 'drifted') {
|
|
86
|
-
reason =
|
|
87
|
-
'Tests passed, but the installed versions do not match uv.lock, so the run does not describe the locked environment.';
|
|
88
|
-
} else if (input.conformance === 'unverifiable') {
|
|
89
|
-
reason =
|
|
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}.`;
|
|
94
|
-
} else if (staleArtifacts) {
|
|
95
|
-
reason = 'Tests passed, but a stale coverage report was detected; refresh it and rerun.';
|
|
96
|
-
}
|
|
40
|
+
export function summarizeValidation(input: ValidationInput): ValidationSummary {
|
|
41
|
+
const summary = coreSummarizeValidation({
|
|
42
|
+
lock: input.lock,
|
|
43
|
+
preparation: input.sync,
|
|
44
|
+
test: input.test,
|
|
45
|
+
quality: input.quality,
|
|
46
|
+
conformance: input.conformance,
|
|
47
|
+
stale: input.stale,
|
|
48
|
+
preview: input.preview,
|
|
49
|
+
labels: {
|
|
50
|
+
lock: 'uv lock --check',
|
|
51
|
+
preparation: 'uv sync',
|
|
52
|
+
stale: 'a stale coverage report',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
97
55
|
return {
|
|
98
|
-
ok:
|
|
99
|
-
reason,
|
|
100
|
-
checks: {
|
|
56
|
+
ok: summary.ok,
|
|
57
|
+
reason: summary.reason,
|
|
58
|
+
checks: {
|
|
59
|
+
lock: summary.checks.lock,
|
|
60
|
+
sync: summary.checks.preparation,
|
|
61
|
+
test: summary.checks.test,
|
|
62
|
+
conformance: summary.checks.conformance,
|
|
63
|
+
quality: summary.checks.quality,
|
|
64
|
+
staleArtifacts: summary.checks.staleArtifacts,
|
|
65
|
+
},
|
|
101
66
|
};
|
|
102
67
|
}
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python's view of the shared completion-evidence gate.
|
|
3
|
+
*
|
|
4
|
+
* The gate itself lives in `pi-helper-core`; this module maps the Python
|
|
5
|
+
* wording ("environment sync") onto the core's preparation stage so call sites
|
|
6
|
+
* keep their existing input shape.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
buildCompletionEvidence as coreBuildCompletionEvidence,
|
|
10
|
+
type CompletionEvidence,
|
|
11
|
+
} from 'pi-helper-core';
|
|
12
|
+
|
|
1
13
|
export interface CompletionEvidenceInput {
|
|
2
14
|
syncExecuted: boolean;
|
|
3
15
|
syncOk: boolean;
|
|
@@ -7,27 +19,19 @@ export interface CompletionEvidenceInput {
|
|
|
7
19
|
changedPaths: string[];
|
|
8
20
|
}
|
|
9
21
|
|
|
10
|
-
export
|
|
11
|
-
ok: boolean;
|
|
12
|
-
blockers: string[];
|
|
13
|
-
changedPaths: string[];
|
|
14
|
-
}
|
|
22
|
+
export type { CompletionEvidence };
|
|
15
23
|
|
|
16
|
-
/**
|
|
17
|
-
* Completion is only proven when the environment was synchronised and the tests
|
|
18
|
-
* actually ran. Declaring completion on a partial run is treated as a blocker,
|
|
19
|
-
* never as a warning.
|
|
20
|
-
*/
|
|
21
24
|
export function buildCompletionEvidence(input: CompletionEvidenceInput): CompletionEvidence {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
return coreBuildCompletionEvidence({
|
|
26
|
+
preparation: {
|
|
27
|
+
name: 'sync',
|
|
28
|
+
label: 'environment sync (uv sync/lock check)',
|
|
29
|
+
executed: input.syncExecuted,
|
|
30
|
+
ok: input.syncOk,
|
|
31
|
+
},
|
|
32
|
+
testExecuted: input.testExecuted,
|
|
33
|
+
testOk: input.testOk,
|
|
34
|
+
stale: input.stale,
|
|
35
|
+
changedPaths: input.changedPaths,
|
|
36
|
+
});
|
|
33
37
|
}
|
package/src/validation/tdd.ts
CHANGED
|
@@ -1,135 +1,26 @@
|
|
|
1
|
-
import { isTestFile } from '../project/paths.ts';
|
|
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
|
-
|
|
17
|
-
export interface TddCheckpoint {
|
|
18
|
-
ok: boolean;
|
|
19
|
-
reasons: string[];
|
|
20
|
-
sourceChanges: string[];
|
|
21
|
-
testChanges: string[];
|
|
22
|
-
associations: TddAssociation[];
|
|
23
|
-
/** True when a match rested only on the shared package prefix. */
|
|
24
|
-
weakAssociation: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Tokens shorter than this cannot distinguish two module names. */
|
|
28
|
-
const MIN_TOKEN_LENGTH = 4;
|
|
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
|
-
|
|
33
|
-
function tokens(path: string): string[] {
|
|
34
|
-
return path
|
|
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));
|
|
39
|
-
}
|
|
40
|
-
|
|
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
1
|
/**
|
|
60
|
-
*
|
|
2
|
+
* Python's view of the shared TDD checkpoint.
|
|
61
3
|
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* directory) yields no exclusions, so an exact name match is never downgraded.
|
|
4
|
+
* The ordering and token-overlap logic live in `pi-helper-core`; this module
|
|
5
|
+
* only supplies which files count as production code or tests in a Python
|
|
6
|
+
* project, so call sites keep their two-argument signature.
|
|
66
7
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
8
|
+
import { checkTdd as coreCheckTdd, type TddSignals } from 'pi-helper-core';
|
|
9
|
+
import { isPythonFile, isTestFile } from '../project/paths.ts';
|
|
10
|
+
|
|
11
|
+
/** Tokens that appear in the prefix of most paths and so cannot distinguish modules. */
|
|
12
|
+
const PYTHON_TDD_SIGNALS: TddSignals = {
|
|
13
|
+
isSourceFile: isPythonFile,
|
|
14
|
+
isTestFile,
|
|
15
|
+
prefixTokens: new Set(['test', 'tests', 'testing', 'src', 'lib']),
|
|
16
|
+
minTokenLength: 4,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function checkTdd(
|
|
20
|
+
changedPaths: string[],
|
|
21
|
+
testChangedPaths: string[] = [],
|
|
22
|
+
): import('pi-helper-core').TddCheckpoint {
|
|
23
|
+
return coreCheckTdd(changedPaths, testChangedPaths, PYTHON_TDD_SIGNALS);
|
|
78
24
|
}
|
|
79
25
|
|
|
80
|
-
|
|
81
|
-
* Check that production changes are accompanied by a plausibly related test
|
|
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.
|
|
89
|
-
*/
|
|
90
|
-
export function checkTdd(changedPaths: string[], testChangedPaths: string[] = []): TddCheckpoint {
|
|
91
|
-
const all = [...new Set([...changedPaths, ...testChangedPaths])].map((path) =>
|
|
92
|
-
path.replace(/\\/g, '/'),
|
|
93
|
-
);
|
|
94
|
-
const sourceChanges = all.filter((path) => path.endsWith('.py') && !isTestFile(path));
|
|
95
|
-
const testChanges = all.filter((path) => path.endsWith('.py') && isTestFile(path));
|
|
96
|
-
|
|
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;
|
|
119
|
-
|
|
120
|
-
const reasons: string[] = [];
|
|
121
|
-
if (sourceChanges.length > 0 && testChanges.length === 0) {
|
|
122
|
-
reasons.push('Production Python files changed without any test file change.');
|
|
123
|
-
} else if (sourceChanges.length > 0 && associations.length === 0) {
|
|
124
|
-
reasons.push('Changed test files do not appear related to the changed production modules.');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
ok: reasons.length === 0,
|
|
129
|
-
reasons,
|
|
130
|
-
sourceChanges,
|
|
131
|
-
testChanges,
|
|
132
|
-
associations,
|
|
133
|
-
weakAssociation,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
26
|
+
export type { TddAssociation, TddCheckpoint } from 'pi-helper-core';
|