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.
@@ -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
- * 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.
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
- export interface PyToolResult<T = unknown> {
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 function result<T>(
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 function failure(
109
- cwd: string,
110
- startedAt: number,
111
- message: string,
112
- code: string,
113
- details?: Partial<PyToolResult>,
114
- ): PyToolResult {
115
- return result(cwd, startedAt, {
116
- ok: false,
117
- summary: message,
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
- /** Shared helper so diagnostics never lose their code when built inline. */
127
- export function warn(code: string, message: string, path?: string, line?: number): Diagnostic {
128
- return { code, message, severity: 'warning', path, line };
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;
@@ -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
- * 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.
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 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
- }
5
+ export { isSpawnFailure, runCommand, type RunOptions, type RunResult } from 'pi-helper-core';
@@ -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
  }
@@ -1,17 +1,16 @@
1
- export interface ValidationStep {
2
- /** Step label, used for the quality checks (`ruff`, `pyright`). */
3
- name?: string;
4
- executed: boolean;
5
- ok: boolean;
6
- exitCode?: number | null;
7
- failures?: number;
8
- /**
9
- * Why a step was not executed. A step that was skipped deliberately (the
10
- * environment is missing the tool it needs) must say so, otherwise the run
11
- * looks like an unexplained test failure.
12
- */
13
- skippedReason?: string;
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
- const PREVIEW_REASON = 'Set execute=true to run the validation bundle.';
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
- }): ValidationSummary {
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
- let reason = 'Lockfile, environment, tests, and installed versions all agree.';
70
- if (input.preview) {
71
- reason = PREVIEW_REASON;
72
- } else if (!input.lock.executed || !input.sync.executed) {
73
- reason = !input.lock.executed
74
- ? 'uv lock --check was not executed, so lockfile agreement is unproven.'
75
- : 'uv sync was not executed, so the environment the tests ran in is unknown.';
76
- } else if (!lock) {
77
- reason = 'uv.lock is out of date; run uv lock before trusting any test result.';
78
- } else if (!sync) {
79
- reason = 'The environment could not be synchronised from the lockfile.';
80
- } else if (!input.test.executed) {
81
- reason =
82
- input.test.skippedReason ?? 'Tests were not executed, so no test result exists to report.';
83
- } else if (!test) {
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: lock && sync && test && conformance && quality && !staleArtifacts,
99
- reason,
100
- checks: { lock, sync, test, conformance, quality, staleArtifacts },
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 interface CompletionEvidence {
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
- const blockers: string[] = [];
23
- if (!input.syncExecuted)
24
- blockers.push('The environment sync (uv sync/lock check) was not executed.');
25
- else if (!input.syncOk) blockers.push('The environment sync did not pass.');
26
- if (!input.testExecuted) blockers.push('Tests were not executed.');
27
- else if (!input.testOk) blockers.push('Tests did not pass.');
28
- if (input.stale)
29
- blockers.push(
30
- 'Stale artifacts were detected, so the test result does not describe the current sources.',
31
- );
32
- return { ok: blockers.length === 0, blockers, changedPaths: input.changedPaths };
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
  }