pi-python-helper 0.4.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,12 @@ 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
+
10
16
  ## [0.4.0] - 2026-09-21
11
17
 
12
18
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-python-helper",
3
- "version": "0.4.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": [
@@ -71,6 +71,6 @@
71
71
  "typescript": "^7.0.2"
72
72
  },
73
73
  "dependencies": {
74
- "pi-helper-core": "^0.1.1"
74
+ "pi-helper-core": "^0.1.2"
75
75
  }
76
76
  }
@@ -1,77 +1,56 @@
1
1
  /**
2
+ * Python's risk rules for the shared classifier.
3
+ *
2
4
  * 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.
5
+ * actuation, so risk is read from the command text. Segment splitting, safe
6
+ * override precedence, and the compound-command merge live in
7
+ * `pi-helper-core`; this module supplies only the package-manager, environment,
8
+ * and migration rules that are specific to Python. The universal rules (git
9
+ * history, file deletion, destructive SQL, containers, pipe-to-shell) are
10
+ * applied by the core and must not be repeated here.
6
11
  */
7
- export type CommandRisk = 'read' | 'mutating' | 'irreversible';
12
+ import {
13
+ classifyCommand as coreClassifyCommand,
14
+ isMutatingCommand as coreIsMutatingCommand,
15
+ riskOf as coreRiskOf,
16
+ type CommandRisk,
17
+ type RiskRule,
18
+ type SafetyRules,
19
+ } from 'pi-helper-core';
8
20
 
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
- }
21
+ export { splitCommandSegments } from 'pi-helper-core';
22
+ export type { CommandClassification, CommandRisk } from 'pi-helper-core';
16
23
 
17
24
  /**
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 .`).
25
+ * Safe overrides are matched before risk patterns so a read-only form does not
26
+ * inherit the risk of the command it qualifies. Only the Python-specific
27
+ * read-only forms live here; `--check`, `--dry-run`, and `--collect-only` are
28
+ * already universal overrides in the core.
20
29
  */
21
- const SAFE_OVERRIDES: RegExp[] = [
22
- /\buv\s+lock\b[^&|;]*--check\b/,
23
- /\buv\s+sync\b[^&|;]*--dry-run\b/,
30
+ const PYTHON_SAFE_OVERRIDES: RegExp[] = [
24
31
  /\buv\s+(?:tree|export|version|help)\b/,
25
32
  /\buv\s+pip\s+(?:list|freeze|check)\b/,
26
- /\bpytest\b[^&|;]*--collect-only\b/,
27
33
  /\bruff\s+(?:check|format)\b[^&|;]*(?:--diff|--check|--no-cache)\b/,
28
34
  /\bmypy\b[^&|;]*--no-incremental\b/,
29
- /\bgit\s+(?:diff|log|status|show|rev-parse|ls-files|branch\s+--show-current)\b/,
30
35
  ];
31
36
 
32
- const RISK_PATTERNS: RiskPattern[] = [
37
+ const PYTHON_RISK_PATTERNS: RiskRule[] = [
33
38
  // Irreversible: cannot be undone by a local revert.
34
39
  {
35
40
  risk: 'irreversible',
36
41
  pattern: /\b(?:uv|poetry|flit|hatch)\s+publish\b|\btwine\s+upload\b/,
37
42
  reason: 'Publishing to a package index is public and cannot be retracted.',
38
43
  },
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
44
  {
50
45
  risk: 'irreversible',
51
46
  pattern: /\b(?:conda|mamba)\s+env\s+remove\b|\bconda\s+remove\b[^&|;]*--all\b/,
52
47
  reason: 'Removing an environment destroys installed state.',
53
48
  },
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
49
  {
66
50
  risk: 'irreversible',
67
51
  pattern: /\b(?:alembic|manage\.py)\b[^&|;]*(?:downgrade|\bzero\b)/,
68
52
  reason: 'Reversing a database migration can drop data.',
69
53
  },
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
54
 
76
55
  // Mutating: changes project, environment, or remote state but is recoverable.
77
56
  {
@@ -104,78 +83,27 @@ const RISK_PATTERNS: RiskPattern[] = [
104
83
  pattern: /\bpre-commit\s+(?:install|autoupdate|run|clean)\b/,
105
84
  reason: 'Rewrites hook configuration or working tree files.',
106
85
  },
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
86
  ];
118
87
 
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/;
88
+ const PYTHON_RULES: Partial<SafetyRules> = {
89
+ safeOverrides: PYTHON_SAFE_OVERRIDES,
90
+ patterns: PYTHON_RISK_PATTERNS,
91
+ };
142
92
 
143
93
  /**
144
94
  * Classify a shell command by the highest risk of its segments. Used to warn
145
95
  * before a tool runs something that cannot be undone, and to gate this
146
96
  * package's own environment-modifying commands behind explicit opt-in.
147
97
  */
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 };
98
+ export function classifyCommand(command: string): import('pi-helper-core').CommandClassification {
99
+ return coreClassifyCommand(command, PYTHON_RULES);
172
100
  }
173
101
 
174
102
  export function isMutatingCommand(command: string): boolean {
175
- return classifyCommand(command).risk !== 'read';
103
+ return coreIsMutatingCommand(command, PYTHON_RULES);
176
104
  }
177
105
 
178
106
  /** Commands that this package runs itself always carry a known risk class. */
179
107
  export function riskOf(args: string[]): CommandRisk {
180
- return classifyCommand(args.join(' ')).risk;
108
+ return coreRiskOf(args, PYTHON_RULES);
181
109
  }