pi-python-helper 0.2.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 +30 -0
- package/docs/tools.md +26 -3
- package/extensions/index.ts +2 -0
- package/extensions/shared.ts +13 -1
- package/extensions/tools/dependencies.ts +1 -1
- package/extensions/tools/environment.ts +7 -13
- package/extensions/tools/test-config.ts +148 -0
- package/extensions/tools/testing.ts +15 -1
- package/extensions/tools/validation.ts +5 -1
- package/helpers/scan_project.py +67 -1
- package/package.json +4 -1
- package/skills/python-development/SKILL.md +8 -5
- package/src/build/failure.ts +25 -16
- package/src/build/pytest-audit.ts +226 -0
- package/src/build/selection.ts +28 -256
- package/src/build/staleness.ts +28 -104
- package/src/core/result.ts +24 -97
- package/src/core/runner.ts +3 -94
- package/src/dependencies/aliases.ts +21 -0
- package/src/project/scanner.ts +11 -1
- package/src/validation/bundle.ts +40 -75
- package/src/validation/evidence.ts +25 -21
- package/src/validation/tdd.ts +21 -130
package/src/core/result.ts
CHANGED
|
@@ -1,102 +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
|
-
|
|
50
|
-
|
|
51
|
-
evidence: Evidence[];
|
|
52
|
-
warnings: Diagnostic[];
|
|
53
|
-
errors: Diagnostic[];
|
|
54
|
-
suggestions: Suggestion[];
|
|
55
|
-
commands?: CommandPreview[];
|
|
56
|
-
metadata: ToolMetadata;
|
|
57
|
-
}
|
|
9
|
+
import { createResultFactory } from 'pi-helper-core';
|
|
10
|
+
import { TOOL_VERSION } from './version.ts';
|
|
11
|
+
|
|
12
|
+
export const { result, failure } = createResultFactory(TOOL_VERSION);
|
|
58
13
|
|
|
59
|
-
export
|
|
60
|
-
cwd: string,
|
|
61
|
-
startedAt: number,
|
|
62
|
-
value: Omit<PyToolResult<T>, 'metadata'> & {
|
|
63
|
-
truncated?: boolean;
|
|
64
|
-
projectRoot?: string;
|
|
65
|
-
pythonVersion?: string;
|
|
66
|
-
},
|
|
67
|
-
): PyToolResult<T> {
|
|
68
|
-
return {
|
|
69
|
-
...value,
|
|
70
|
-
metadata: {
|
|
71
|
-
toolVersion: TOOL_VERSION,
|
|
72
|
-
cwd,
|
|
73
|
-
durationMs: Date.now() - startedAt,
|
|
74
|
-
truncated: value.truncated ?? false,
|
|
75
|
-
projectRoot: value.projectRoot,
|
|
76
|
-
pythonVersion: value.pythonVersion,
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
14
|
+
export { CORE_SCHEMA_VERSION, isActionable, note, warn } from 'pi-helper-core';
|
|
80
15
|
|
|
81
|
-
export
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
evidence: [],
|
|
92
|
-
warnings: [],
|
|
93
|
-
errors: [{ code, message, severity: 'error' }],
|
|
94
|
-
suggestions: [],
|
|
95
|
-
...details,
|
|
96
|
-
});
|
|
97
|
-
}
|
|
16
|
+
export type {
|
|
17
|
+
CommandPreview,
|
|
18
|
+
CommandRisk,
|
|
19
|
+
Diagnostic,
|
|
20
|
+
Evidence,
|
|
21
|
+
Severity,
|
|
22
|
+
Suggestion,
|
|
23
|
+
ToolchainInfo,
|
|
24
|
+
ToolResult,
|
|
25
|
+
} from 'pi-helper-core';
|
|
98
26
|
|
|
99
|
-
/**
|
|
100
|
-
export
|
|
101
|
-
|
|
102
|
-
}
|
|
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';
|
|
@@ -65,6 +65,27 @@ export const IMPORT_ALIASES: Record<string, string[]> = {
|
|
|
65
65
|
tqdm: ['tqdm'],
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const NORMALIZE_RE = /[-_.]+/g;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Distribution candidates for an import name, from the static alias table only.
|
|
72
|
+
*
|
|
73
|
+
* Used where the scanner cannot supply the authoritative provider mapping (a
|
|
74
|
+
* traceback names an import, not an installed distribution). Ordering is
|
|
75
|
+
* preserved so a caller can name every candidate when an import maps to more
|
|
76
|
+
* than one distribution: `cv2` is either `opencv-python` or
|
|
77
|
+
* `opencv-python-headless`, and picking one silently would install the wrong
|
|
78
|
+
* variant. An empty result means the provider is unknown, and no `uv add`
|
|
79
|
+
* command may be fabricated from the import name.
|
|
80
|
+
*/
|
|
81
|
+
export function importAliasCandidates(importName: string): string[] {
|
|
82
|
+
const normalized = importName.replace(NORMALIZE_RE, '-').trim().toLowerCase();
|
|
83
|
+
const candidates = new Set<string>();
|
|
84
|
+
for (const alias of IMPORT_ALIASES[importName] ?? []) candidates.add(alias);
|
|
85
|
+
for (const alias of IMPORT_ALIASES[normalized] ?? []) candidates.add(alias);
|
|
86
|
+
return [...candidates];
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/** Distributions that are normally invoked as a console script, not imported. */
|
|
69
90
|
export const CONSOLE_ONLY: Set<string> = new Set([
|
|
70
91
|
'ruff',
|
package/src/project/scanner.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type ScanMode =
|
|
|
14
14
|
| 'manifest,imports';
|
|
15
15
|
|
|
16
16
|
/** Bumped by the scanner when the request or result document changes shape. */
|
|
17
|
-
export const SUPPORTED_SCANNER_VERSION =
|
|
17
|
+
export const SUPPORTED_SCANNER_VERSION = 3;
|
|
18
18
|
|
|
19
19
|
export interface DeclaredDependency {
|
|
20
20
|
raw: string;
|
|
@@ -40,6 +40,12 @@ export interface ManifestSection {
|
|
|
40
40
|
buildRequires: string[];
|
|
41
41
|
entryPoints: string[];
|
|
42
42
|
toolConfiguration: Record<string, boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* `[tool.pytest.ini_options]` as written, or null when the table is absent.
|
|
45
|
+
* Left untyped because pytest accepts options this package does not model;
|
|
46
|
+
* only the audited keys are read.
|
|
47
|
+
*/
|
|
48
|
+
pytestOptions?: Record<string, unknown> | null;
|
|
43
49
|
layout: 'src' | 'flat';
|
|
44
50
|
modules: string[];
|
|
45
51
|
legacySetupPy: boolean;
|
|
@@ -98,6 +104,10 @@ export interface ImportSection {
|
|
|
98
104
|
*/
|
|
99
105
|
importModules?: string[];
|
|
100
106
|
typeCheckingImports: string[];
|
|
107
|
+
/** `async def test_*` names in this file. */
|
|
108
|
+
asyncTests?: string[];
|
|
109
|
+
/** The subset of `asyncTests` that carries an async plugin marker. */
|
|
110
|
+
asyncioMarkedTests?: string[];
|
|
101
111
|
}[];
|
|
102
112
|
thirdParty: {
|
|
103
113
|
import: string;
|
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';
|