pi-python-helper 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -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/core/safety.ts +32 -104
- 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,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/core/safety.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
19
|
-
* inherit the risk of the command it qualifies
|
|
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
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
108
|
+
return coreRiskOf(args, PYTHON_RULES);
|
|
181
109
|
}
|
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
|
}
|