pi-helper-core 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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +17 -0
- package/README.md +86 -0
- package/package.json +42 -0
- package/src/adapter.ts +152 -0
- package/src/build/staleness.ts +147 -0
- package/src/core/result.ts +205 -0
- package/src/core/runner.ts +128 -0
- package/src/core/safety.ts +167 -0
- package/src/index.ts +102 -0
- package/src/selection/select.ts +322 -0
- package/src/validation/bundle.ts +131 -0
- package/src/validation/evidence.ts +48 -0
- package/src/validation/tdd.ts +152 -0
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
/** Cap applied to stdout and stderr independently. */
|
|
9
|
+
maxBytes?: number;
|
|
10
|
+
stdin?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunResult {
|
|
14
|
+
code: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
timedOut: boolean;
|
|
18
|
+
cancelled: boolean;
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function appendLimited(current: string, chunk: string, maxBytes: number): [string, boolean] {
|
|
24
|
+
const next = current + chunk;
|
|
25
|
+
if (Buffer.byteLength(next, 'utf8') <= maxBytes) return [next, false];
|
|
26
|
+
return [Buffer.from(next, 'utf8').subarray(0, maxBytes).toString('utf8'), true];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Single bounded subprocess entry point for every helper.
|
|
31
|
+
*
|
|
32
|
+
* User-supplied paths and package names must always reach the process through
|
|
33
|
+
* `args`, never through a shell string, and every call must stay inside a
|
|
34
|
+
* timeout and an output cap. Killing the whole process group matters because a
|
|
35
|
+
* toolchain driver spawns compilers and linkers that would otherwise survive.
|
|
36
|
+
*/
|
|
37
|
+
export async function runCommand(
|
|
38
|
+
executable: string,
|
|
39
|
+
args: string[],
|
|
40
|
+
options: RunOptions,
|
|
41
|
+
): Promise<RunResult> {
|
|
42
|
+
const maxBytes = options.maxBytes ?? 100 * 1024;
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
const child = spawn(executable, args, {
|
|
45
|
+
cwd: options.cwd,
|
|
46
|
+
env: { ...process.env, ...options.env },
|
|
47
|
+
detached: process.platform !== 'win32',
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (options.stdin !== undefined) child.stdin.write(options.stdin);
|
|
52
|
+
child.stdin.end();
|
|
53
|
+
|
|
54
|
+
let stdout = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
let truncated = false;
|
|
57
|
+
let timedOut = false;
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
let spawnError: Error | undefined;
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
cancelled = true;
|
|
62
|
+
terminate(child);
|
|
63
|
+
};
|
|
64
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
65
|
+
const timeout = options.timeoutMs
|
|
66
|
+
? setTimeout(() => {
|
|
67
|
+
timedOut = true;
|
|
68
|
+
terminate(child);
|
|
69
|
+
}, options.timeoutMs)
|
|
70
|
+
: undefined;
|
|
71
|
+
|
|
72
|
+
// A missing executable arrives here rather than as a rejection, so the
|
|
73
|
+
// message is folded into stderr where a failure diagnosis already looks.
|
|
74
|
+
child.on('error', (error) => {
|
|
75
|
+
spawnError = error;
|
|
76
|
+
});
|
|
77
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
78
|
+
const [next, wasTruncated] = appendLimited(stdout, chunk.toString(), maxBytes);
|
|
79
|
+
stdout = next;
|
|
80
|
+
truncated ||= wasTruncated;
|
|
81
|
+
});
|
|
82
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
83
|
+
const [next, wasTruncated] = appendLimited(stderr, chunk.toString(), maxBytes);
|
|
84
|
+
stderr = next;
|
|
85
|
+
truncated ||= wasTruncated;
|
|
86
|
+
});
|
|
87
|
+
const code = await new Promise<number | null>((resolve) =>
|
|
88
|
+
child.once('close', (exitCode) => resolve(exitCode)),
|
|
89
|
+
);
|
|
90
|
+
if (timeout) clearTimeout(timeout);
|
|
91
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
92
|
+
if (spawnError) stderr = `${stderr}${stderr ? '\n' : ''}${spawnError.message}`;
|
|
93
|
+
return {
|
|
94
|
+
code,
|
|
95
|
+
stdout,
|
|
96
|
+
stderr,
|
|
97
|
+
timedOut,
|
|
98
|
+
cancelled,
|
|
99
|
+
truncated,
|
|
100
|
+
durationMs: Date.now() - startedAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function terminate(child: ReturnType<typeof spawn>): void {
|
|
105
|
+
if (child.pid === undefined) return;
|
|
106
|
+
try {
|
|
107
|
+
// The negative pid targets the process group, so child compilers die too.
|
|
108
|
+
if (process.platform !== 'win32') process.kill(-child.pid, 'SIGTERM');
|
|
109
|
+
else child.kill('SIGTERM');
|
|
110
|
+
} catch {
|
|
111
|
+
child.kill('SIGTERM');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A command failed to start at all, which is an environment problem.
|
|
116
|
+
*
|
|
117
|
+
* Node reports a spawn error through `error` and then closes the child with a
|
|
118
|
+
* negative code, so the exit code alone cannot identify this case; the message
|
|
119
|
+
* is the reliable signal.
|
|
120
|
+
*/
|
|
121
|
+
export function isSpawnFailure(result: RunResult): boolean {
|
|
122
|
+
return (
|
|
123
|
+
!result.timedOut &&
|
|
124
|
+
!result.cancelled &&
|
|
125
|
+
result.code !== 0 &&
|
|
126
|
+
/ENOENT|EACCES|not found|Failed to spawn|command not found/i.test(result.stderr)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { CommandRisk } from './result.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Risk is inferred from the command text, never from a domain name, because no
|
|
5
|
+
* toolchain has a deterministic signal like `cmd_vel`. A compound command
|
|
6
|
+
* inherits the highest risk of its segments so nothing hides behind a safe
|
|
7
|
+
* sibling.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const RISK_ORDER: Record<CommandRisk, number> = { read: 0, mutating: 1, irreversible: 2 };
|
|
11
|
+
|
|
12
|
+
export interface RiskRule {
|
|
13
|
+
pattern: RegExp;
|
|
14
|
+
risk: Exclude<CommandRisk, 'read'>;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SafetyRules {
|
|
19
|
+
/**
|
|
20
|
+
* Matched before risk patterns so a read-only flag does not inherit the risk
|
|
21
|
+
* of the command it qualifies (`--check`, `--dry-run`, `--collect-only`).
|
|
22
|
+
*/
|
|
23
|
+
safeOverrides: RegExp[];
|
|
24
|
+
patterns: RiskRule[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read-only forms that would otherwise inherit their command's risk.
|
|
29
|
+
* Ecosystem-neutral: these are generic flags, not tool names.
|
|
30
|
+
*/
|
|
31
|
+
export const UNIVERSAL_SAFE_OVERRIDES: RegExp[] = [
|
|
32
|
+
/\bgit\s+(?:diff|log|status|show|rev-parse|ls-files|cat-file|describe|rev-list)\b/,
|
|
33
|
+
/\bgit\s+branch\s+--show-current\b/,
|
|
34
|
+
/(?:^|\s)--(?:check|dry-run|collect-only|list|frozen|locked)\b/,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Risks that do not depend on the ecosystem: repository history, the file
|
|
39
|
+
* system, containers, and databases behave the same everywhere. Anything that
|
|
40
|
+
* names a package manager belongs to the adapter.
|
|
41
|
+
*/
|
|
42
|
+
export const UNIVERSAL_RISK_PATTERNS: RiskRule[] = [
|
|
43
|
+
// Irreversible: cannot be undone by a local revert.
|
|
44
|
+
{
|
|
45
|
+
risk: 'irreversible',
|
|
46
|
+
pattern: /\bgit\s+push\b[^&|;]*(?:--force(?:-with-lease)?|(?<![\w-])-f(?![a-z]))/,
|
|
47
|
+
reason: 'Force pushing rewrites shared remote history.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
risk: 'irreversible',
|
|
51
|
+
pattern: /\bgit\s+(?:reset\s+--hard|clean\b[^&|;]*-[a-z]*f)/,
|
|
52
|
+
reason: 'Hard reset or clean discards uncommitted work permanently.',
|
|
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: /\bdocker\s+(?:system|volume|image)\s+(?:prune|rm)\b/,
|
|
68
|
+
reason: 'Docker prune removes volumes or images outside the project.',
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Mutating: recoverable, but changes project, repository, or remote state.
|
|
72
|
+
{
|
|
73
|
+
risk: 'mutating',
|
|
74
|
+
pattern: /\bgit\s+(?:commit|add|checkout|switch|restore|stash|merge|rebase|push|tag|init)\b/,
|
|
75
|
+
reason: 'Changes repository or remote state.',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
risk: 'mutating',
|
|
79
|
+
pattern: /\b(?:rm|mv|chmod|chown|truncate)\b/,
|
|
80
|
+
reason: 'Changes files on disk.',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `curl ... | sh` is invisible after segment splitting because the pipe itself
|
|
86
|
+
* is the hazard, so it is matched against the whole command first.
|
|
87
|
+
*/
|
|
88
|
+
const PIPE_TO_SHELL = /\b(?:curl|wget)\b[^;&\n]*\|\s*(?:sudo\s+)?(?:ba|z|k)?sh\b/;
|
|
89
|
+
|
|
90
|
+
export interface CommandClassification {
|
|
91
|
+
risk: CommandRisk;
|
|
92
|
+
reasons: { segment: string; risk: CommandRisk; reason: string }[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Split a compound command so no segment can hide behind a safe sibling. */
|
|
96
|
+
export function splitCommandSegments(command: string): string[] {
|
|
97
|
+
return command
|
|
98
|
+
.split(/&&|\|\||;|\n|\|/)
|
|
99
|
+
.map((segment) => segment.trim())
|
|
100
|
+
.filter((segment) => segment.length > 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeRules(rules?: Partial<SafetyRules>): SafetyRules {
|
|
104
|
+
return {
|
|
105
|
+
safeOverrides: [...UNIVERSAL_SAFE_OVERRIDES, ...(rules?.safeOverrides ?? [])],
|
|
106
|
+
patterns: [...UNIVERSAL_RISK_PATTERNS, ...(rules?.patterns ?? [])],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function classifySegment(
|
|
111
|
+
segment: string,
|
|
112
|
+
rules: SafetyRules,
|
|
113
|
+
): { risk: CommandRisk; reason?: string } {
|
|
114
|
+
if (rules.safeOverrides.some((pattern) => pattern.test(segment))) return { risk: 'read' };
|
|
115
|
+
for (const entry of rules.patterns) {
|
|
116
|
+
if (entry.pattern.test(segment)) return { risk: entry.risk, reason: entry.reason };
|
|
117
|
+
}
|
|
118
|
+
return { risk: 'read' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Classify a shell command by the highest risk of its segments. Used to warn
|
|
123
|
+
* before running something that cannot be undone, and to gate a helper's own
|
|
124
|
+
* state-changing commands behind explicit opt-in.
|
|
125
|
+
*
|
|
126
|
+
* The adapter supplies only the ecosystem-specific rules; the universal ones
|
|
127
|
+
* are always applied, so a new helper cannot forget `git push --force`.
|
|
128
|
+
*/
|
|
129
|
+
export function classifyCommand(
|
|
130
|
+
command: string,
|
|
131
|
+
rules?: Partial<SafetyRules>,
|
|
132
|
+
): CommandClassification {
|
|
133
|
+
const merged = mergeRules(rules);
|
|
134
|
+
const piped = command.match(PIPE_TO_SHELL);
|
|
135
|
+
if (piped) {
|
|
136
|
+
return {
|
|
137
|
+
risk: 'irreversible',
|
|
138
|
+
reasons: [
|
|
139
|
+
{
|
|
140
|
+
segment: piped[0].trim(),
|
|
141
|
+
risk: 'irreversible',
|
|
142
|
+
reason: 'Piping a download into a shell runs unreviewed code.',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const reasons: CommandClassification['reasons'] = [];
|
|
149
|
+
let risk: CommandRisk = 'read';
|
|
150
|
+
for (const segment of splitCommandSegments(command)) {
|
|
151
|
+
const classified = classifySegment(segment, merged);
|
|
152
|
+
if (RISK_ORDER[classified.risk] > RISK_ORDER[risk]) risk = classified.risk;
|
|
153
|
+
if (classified.risk !== 'read' && classified.reason) {
|
|
154
|
+
reasons.push({ segment, risk: classified.risk, reason: classified.reason });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { risk, reasons };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function isMutatingCommand(command: string, rules?: Partial<SafetyRules>): boolean {
|
|
161
|
+
return classifyCommand(command, rules).risk !== 'read';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Commands a helper runs itself always carry a known risk class. */
|
|
165
|
+
export function riskOf(args: string[], rules?: Partial<SafetyRules>): CommandRisk {
|
|
166
|
+
return classifyCommand(args.join(' '), rules).risk;
|
|
167
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ecosystem-neutral core for pi language helper extensions.
|
|
3
|
+
*
|
|
4
|
+
* A helper keeps the ecosystem knowledge (command names, manifest formats,
|
|
5
|
+
* output parsers) and imports everything shared from here: the response
|
|
6
|
+
* envelope, the bounded command runner, risk classification, the completion
|
|
7
|
+
* gates, artifact staleness, and test selection.
|
|
8
|
+
*
|
|
9
|
+
* Nothing in this package may name an ecosystem. `test/purity.test.ts` enforces
|
|
10
|
+
* that, because the whole point of extracting the core is that a new helper
|
|
11
|
+
* only has to write its adapter.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
CORE_SCHEMA_VERSION,
|
|
16
|
+
createResultFactory,
|
|
17
|
+
isActionable,
|
|
18
|
+
note,
|
|
19
|
+
warn,
|
|
20
|
+
type CommandPreview,
|
|
21
|
+
type CommandRisk,
|
|
22
|
+
type Diagnostic,
|
|
23
|
+
type Evidence,
|
|
24
|
+
type ResultFactory,
|
|
25
|
+
type ResultInput,
|
|
26
|
+
type Severity,
|
|
27
|
+
type Suggestion,
|
|
28
|
+
type ToolchainInfo,
|
|
29
|
+
type ToolMetadata,
|
|
30
|
+
type ToolResult,
|
|
31
|
+
} from './core/result.ts';
|
|
32
|
+
|
|
33
|
+
export { isSpawnFailure, runCommand, type RunOptions, type RunResult } from './core/runner.ts';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
classifyCommand,
|
|
37
|
+
isMutatingCommand,
|
|
38
|
+
riskOf,
|
|
39
|
+
splitCommandSegments,
|
|
40
|
+
UNIVERSAL_RISK_PATTERNS,
|
|
41
|
+
UNIVERSAL_SAFE_OVERRIDES,
|
|
42
|
+
type CommandClassification,
|
|
43
|
+
type RiskRule,
|
|
44
|
+
type SafetyRules,
|
|
45
|
+
} from './core/safety.ts';
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
buildCompletionEvidence,
|
|
49
|
+
type CompletionEvidence,
|
|
50
|
+
type CompletionEvidenceInput,
|
|
51
|
+
type PreparationStage,
|
|
52
|
+
} from './validation/evidence.ts';
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
checkTdd,
|
|
56
|
+
type TddAssociation,
|
|
57
|
+
type TddCheckpoint,
|
|
58
|
+
type TddSignals,
|
|
59
|
+
} from './validation/tdd.ts';
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
summarizeValidation,
|
|
63
|
+
type ValidationLabels,
|
|
64
|
+
type ValidationStep,
|
|
65
|
+
type ValidationSummary,
|
|
66
|
+
} from './validation/bundle.ts';
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
detectStaleArtifacts,
|
|
70
|
+
UNIVERSAL_IGNORED_DIRECTORIES,
|
|
71
|
+
type StaleArtifact,
|
|
72
|
+
type StaleArtifactSpec,
|
|
73
|
+
type StalenessReport,
|
|
74
|
+
type StalenessSpec,
|
|
75
|
+
} from './build/staleness.ts';
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
DEFAULT_SCORE,
|
|
79
|
+
normalizedStem,
|
|
80
|
+
selectTests,
|
|
81
|
+
type SelectionOptions,
|
|
82
|
+
type SelectionResult,
|
|
83
|
+
type SelectionSignals,
|
|
84
|
+
type TestImportMap,
|
|
85
|
+
type TestSelection,
|
|
86
|
+
} from './selection/select.ts';
|
|
87
|
+
|
|
88
|
+
export {
|
|
89
|
+
defineAdapter,
|
|
90
|
+
type AdapterContext,
|
|
91
|
+
type CheckCommandInput,
|
|
92
|
+
type DerivedArtifact,
|
|
93
|
+
type EcosystemAdapter,
|
|
94
|
+
type FailureDiagnosis,
|
|
95
|
+
type FailureFrame,
|
|
96
|
+
type ProjectModel,
|
|
97
|
+
type ProjectPackage,
|
|
98
|
+
type TestCommandInput,
|
|
99
|
+
type TestCounts,
|
|
100
|
+
type TestFailure,
|
|
101
|
+
type TestReport,
|
|
102
|
+
} from './adapter.ts';
|