pi-python-helper 0.1.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.
@@ -0,0 +1,117 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
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
+ }
11
+
12
+ export interface PythonStalenessReport {
13
+ stale: boolean;
14
+ artifacts: StaleArtifact[];
15
+ /** Populated when the check could not be completed, so `stale: false` is not overclaimed. */
16
+ incompleteReason?: string;
17
+ }
18
+
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
+ }
75
+
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
+ 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.' };
99
+ }
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 };
117
+ }
@@ -0,0 +1,102 @@
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
+ /**
43
+ * Every tool in this package returns this shape so the agent can rely on a
44
+ * single contract regardless of which diagnostic ran. Domain fields live in
45
+ * `data`; everything the agent must reason about lives in the typed sections.
46
+ */
47
+ export interface PyToolResult<T = unknown> {
48
+ ok: boolean;
49
+ summary: string;
50
+ data?: T;
51
+ evidence: Evidence[];
52
+ warnings: Diagnostic[];
53
+ errors: Diagnostic[];
54
+ suggestions: Suggestion[];
55
+ commands?: CommandPreview[];
56
+ metadata: ToolMetadata;
57
+ }
58
+
59
+ export function result<T>(
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
+ }
80
+
81
+ export function failure(
82
+ cwd: string,
83
+ startedAt: number,
84
+ message: string,
85
+ code: string,
86
+ details?: Partial<PyToolResult>,
87
+ ): PyToolResult {
88
+ return result(cwd, startedAt, {
89
+ ok: false,
90
+ summary: message,
91
+ evidence: [],
92
+ warnings: [],
93
+ errors: [{ code, message, severity: 'error' }],
94
+ suggestions: [],
95
+ ...details,
96
+ });
97
+ }
98
+
99
+ /** Shared helper so diagnostics never lose their code when built inline. */
100
+ export function warn(code: string, message: string, path?: string, line?: number): Diagnostic {
101
+ return { code, message, severity: 'warning', path, line };
102
+ }
@@ -0,0 +1,96 @@
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
+ /**
28
+ * Single bounded subprocess entry point. User-supplied paths and package names
29
+ * must always reach the process through `args`, never through a shell string,
30
+ * and every call must stay inside a timeout and an output cap.
31
+ */
32
+ export async function runCommand(
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
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Python has no equivalent of a `cmd_vel` topic that deterministically signals
3
+ * actuation, so risk cannot be inferred from a domain name. Instead every
4
+ * command is classified from its own text, and a compound command inherits the
5
+ * highest risk of its segments.
6
+ */
7
+ export type CommandRisk = 'read' | 'mutating' | 'irreversible';
8
+
9
+ const RISK_ORDER: Record<CommandRisk, number> = { read: 0, mutating: 1, irreversible: 2 };
10
+
11
+ interface RiskPattern {
12
+ risk: Exclude<CommandRisk, 'read'>;
13
+ pattern: RegExp;
14
+ reason: string;
15
+ }
16
+
17
+ /**
18
+ * Safe overrides are matched before risk patterns so a read-only flag does not
19
+ * inherit the risk of the command it qualifies (`uv lock --check`, `-e .`).
20
+ */
21
+ const SAFE_OVERRIDES: RegExp[] = [
22
+ /\buv\s+lock\b[^&|;]*--check\b/,
23
+ /\buv\s+sync\b[^&|;]*--dry-run\b/,
24
+ /\buv\s+(?:tree|export|version|help)\b/,
25
+ /\buv\s+pip\s+(?:list|freeze|check)\b/,
26
+ /\bpytest\b[^&|;]*--collect-only\b/,
27
+ /\bruff\s+(?:check|format)\b[^&|;]*(?:--diff|--check|--no-cache)\b/,
28
+ /\bmypy\b[^&|;]*--no-incremental\b/,
29
+ /\bgit\s+(?:diff|log|status|show|rev-parse|ls-files|branch\s+--show-current)\b/,
30
+ ];
31
+
32
+ const RISK_PATTERNS: RiskPattern[] = [
33
+ // Irreversible: cannot be undone by a local revert.
34
+ {
35
+ risk: 'irreversible',
36
+ pattern: /\b(?:uv|poetry|flit|hatch)\s+publish\b|\btwine\s+upload\b/,
37
+ reason: 'Publishing to a package index is public and cannot be retracted.',
38
+ },
39
+ {
40
+ risk: 'irreversible',
41
+ pattern: /\bgit\s+push\b[^&|;]*(?:--force(?:-with-lease)?|-f\b)/,
42
+ reason: 'Force pushing rewrites shared remote history.',
43
+ },
44
+ {
45
+ risk: 'irreversible',
46
+ pattern: /\bgit\s+(?:reset\s+--hard|clean\b[^&|;]*-[a-z]*f)/,
47
+ reason: 'Hard reset or clean discards uncommitted work permanently.',
48
+ },
49
+ {
50
+ risk: 'irreversible',
51
+ pattern: /\b(?:conda|mamba)\s+env\s+remove\b|\bconda\s+remove\b[^&|;]*--all\b/,
52
+ reason: 'Removing an environment destroys installed state.',
53
+ },
54
+ {
55
+ risk: 'irreversible',
56
+ pattern: /\brm\b[^&|;]*-[a-z]*[rf][a-z]*/,
57
+ reason: 'Recursive or forced deletion is not recoverable.',
58
+ },
59
+ {
60
+ risk: 'irreversible',
61
+ pattern:
62
+ /\b(?:drop|truncate)\s+(?:table|database|schema)\b|\bdelete\s+from\b(?![^&|;]*\bwhere\b)/i,
63
+ reason: 'Destructive SQL without a narrowing predicate.',
64
+ },
65
+ {
66
+ risk: 'irreversible',
67
+ pattern: /\b(?:alembic|manage\.py)\b[^&|;]*(?:downgrade|\bzero\b)/,
68
+ reason: 'Reversing a database migration can drop data.',
69
+ },
70
+ {
71
+ risk: 'irreversible',
72
+ pattern: /\bdocker\s+(?:system|volume|image)\s+(?:prune|rm)\b/,
73
+ reason: 'Docker prune removes volumes or images outside the project.',
74
+ },
75
+
76
+ // Mutating: changes project, environment, or remote state but is recoverable.
77
+ {
78
+ risk: 'mutating',
79
+ pattern: /\b(?:uv|pdm|poetry)\s+(?:add|remove|sync|lock|update|venv)\b/,
80
+ reason: 'Modifies the environment or the lockfile.',
81
+ },
82
+ {
83
+ risk: 'mutating',
84
+ pattern: /\buv\s+pip\s+(?:install|uninstall|sync)\b/,
85
+ reason: 'Changes installed packages in the active environment.',
86
+ },
87
+ {
88
+ risk: 'mutating',
89
+ pattern: /\b(?:pip|pip3)\s+(?:install|uninstall)\b/,
90
+ reason: 'Changes installed packages in the active environment.',
91
+ },
92
+ {
93
+ risk: 'mutating',
94
+ pattern: /\b(?:conda|mamba)\s+(?:install|create|update|remove)\b/,
95
+ reason: 'Changes conda environment state.',
96
+ },
97
+ {
98
+ risk: 'mutating',
99
+ pattern: /\b(?:alembic|manage\.py)\b[^&|;]*\b(?:upgrade|migrate|makemigrations)\b/,
100
+ reason: 'Applies a schema change to a database.',
101
+ },
102
+ {
103
+ risk: 'mutating',
104
+ pattern: /\bpre-commit\s+(?:install|autoupdate|run|clean)\b/,
105
+ reason: 'Rewrites hook configuration or working tree files.',
106
+ },
107
+ {
108
+ risk: 'mutating',
109
+ pattern: /\bgit\s+(?:commit|add|checkout|switch|restore|stash|merge|rebase|push|tag)\b/,
110
+ reason: 'Changes repository or remote state.',
111
+ },
112
+ {
113
+ risk: 'mutating',
114
+ pattern: /\b(?:rm|mv|chmod|chown|truncate)\b/,
115
+ reason: 'Changes files on disk.',
116
+ },
117
+ ];
118
+
119
+ /** Split a compound command so no segment can hide behind a safe sibling. */
120
+ export function splitCommandSegments(command: string): string[] {
121
+ return command
122
+ .split(/&&|\|\||;|\n|\|/)
123
+ .map((segment) => segment.trim())
124
+ .filter((segment) => segment.length > 0);
125
+ }
126
+
127
+ function classifySegment(segment: string): { risk: CommandRisk; reason?: string } {
128
+ if (SAFE_OVERRIDES.some((pattern) => pattern.test(segment))) return { risk: 'read' };
129
+ for (const entry of RISK_PATTERNS) {
130
+ if (entry.pattern.test(segment)) return { risk: entry.risk, reason: entry.reason };
131
+ }
132
+ return { risk: 'read' };
133
+ }
134
+
135
+ export interface CommandClassification {
136
+ risk: CommandRisk;
137
+ reasons: { segment: string; risk: CommandRisk; reason: string }[];
138
+ }
139
+
140
+ /** `curl ... | sh` cannot be seen after segment splitting, so it is matched first. */
141
+ const PIPE_TO_SHELL = /\b(?:curl|wget)\b[^;&\n]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/;
142
+
143
+ /**
144
+ * Classify a shell command by the highest risk of its segments. Used to warn
145
+ * before a tool runs something that cannot be undone, and to gate this
146
+ * package's own environment-modifying commands behind explicit opt-in.
147
+ */
148
+ export function classifyCommand(command: string): CommandClassification {
149
+ const piped = command.match(PIPE_TO_SHELL);
150
+ if (piped) {
151
+ return {
152
+ risk: 'irreversible',
153
+ reasons: [
154
+ {
155
+ segment: piped[0].trim(),
156
+ risk: 'irreversible',
157
+ reason: 'Piping a download into a shell runs unreviewed code.',
158
+ },
159
+ ],
160
+ };
161
+ }
162
+ const reasons: CommandClassification['reasons'] = [];
163
+ let risk: CommandRisk = 'read';
164
+ for (const segment of splitCommandSegments(command)) {
165
+ const classified = classifySegment(segment);
166
+ if (RISK_ORDER[classified.risk] > RISK_ORDER[risk]) risk = classified.risk;
167
+ if (classified.risk !== 'read' && classified.reason) {
168
+ reasons.push({ segment, risk: classified.risk, reason: classified.reason });
169
+ }
170
+ }
171
+ return { risk, reasons };
172
+ }
173
+
174
+ export function isMutatingCommand(command: string): boolean {
175
+ return classifyCommand(command).risk !== 'read';
176
+ }
177
+
178
+ /** Commands that this package runs itself always carry a known risk class. */
179
+ export function riskOf(args: string[]): CommandRisk {
180
+ return classifyCommand(args.join(' ')).risk;
181
+ }
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Report the version of the installed package so tool metadata does not drift
5
+ * from `package.json` across releases.
6
+ */
7
+ function readPackageVersion(): string {
8
+ try {
9
+ const manifest = JSON.parse(
10
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
11
+ ) as { version?: unknown };
12
+ return typeof manifest.version === 'string' && manifest.version.length > 0
13
+ ? manifest.version
14
+ : '0.0.0';
15
+ } catch {
16
+ return '0.0.0';
17
+ }
18
+ }
19
+
20
+ export const TOOL_VERSION = readPackageVersion();