Distinguish complete vs fragment prompts to prevent false stripping

Split trailing prompt patterns into two categories:
- Complete prompts (user@host:~ $, PS C:\>, etc.) stop stripping
  immediately — anything above is command output, not a wrapped prompt
- Fragment patterns (er$, ] $, [host:~/path...) allow continued
  stripping to reassemble wrapped prompts

This prevents falsely stripping output lines that happen to end with
$ or # when a real complete prompt sits below them. Added adversarial
tests verifying correct behavior for output containing prompt-like
characters.
This commit is contained in:
Alex Dima
2026-03-22 10:10:09 +01:00
parent 9644aa33b4
commit b2b4e0e207
2 changed files with 156 additions and 37 deletions

View File

@@ -134,44 +134,59 @@ function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log
const maxTrailingPromptLines = 2;
while (endIndex > startIndex) {
const line = lines[endIndex - 1].trimEnd();
if (
line.length === 0 ||
(trailingStrippedCount < maxTrailingPromptLines && (
// Bash/zsh prompt: user@host:path ending with $ or #
// e.g., "user@host:~/src $ " or "root@server:/var/log# "
(!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line) ||
// Prompt without @: hostname:path user$ or hostname:path user#
// e.g., "dsm12-be220-abc:testWorkspace runner$"
(!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line) ||
// Wrapped prompt fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$")
// These appear when a prompt wraps across terminal columns.
(!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line) ||
// Bracketed prompt start: [ hostname:/path or [ user@host:/path (wrapped prompt first line)
// e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test"
// e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte"
(!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line) ||
// Wrapped prompt continuation: user@host:path or hostname:path (no trailing $)
// Only matched after we've already stripped a prompt fragment below.
// e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn"
(!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line) ||
// Bracketed prompt end: ...] $ or ...] #
// e.g., "s/testWorkspace (main**) ] $ "
(!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line) ||
// PowerShell prompt: PS C:\path>
(!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line) ||
// Windows cmd prompt: C:\path>
(!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line) ||
// Starship prompt character
// allow-any-unicode-next-line
(!knownPrompt || isStarship) && /\u276f\s*$/.test(line) ||
// Python REPL prompt
(!knownPrompt || isPython) && /^>>>\s*$/.test(line)
))
) {
if (line.length === 0) {
endIndex--;
if (line.length > 0) {
trailingStrippedCount++;
}
continue;
}
if (trailingStrippedCount >= maxTrailingPromptLines) {
break;
}
// Complete (self-contained) prompt patterns: these have a recognizable
// prefix and a trailing marker ($, #, >). After stripping one complete
// prompt line, stop — lines above it are command output, not wrapped
// prompt continuation lines.
const isCompletePrompt =
// Bash/zsh: user@host:path ending with $ or #
// e.g., "user@host:~/src $ " or "root@server:/var/log# "
((!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line)) ||
// hostname:path user$ or hostname:path user#
// e.g., "dsm12-be220-abc:testWorkspace runner$"
((!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line)) ||
// PowerShell: PS C:\path>
((!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line)) ||
// Windows cmd: C:\path>
((!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line)) ||
// Starship prompt character
// allow-any-unicode-next-line
((!knownPrompt || isStarship) && /\u276f\s*$/.test(line)) ||
// Python REPL
((!knownPrompt || isPython) && /^>>>\s*$/.test(line));
// Fragment/partial prompt patterns: these represent pieces of a prompt
// that wraps across multiple terminal lines due to column width.
const isPromptFragment =
// Wrapped fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$")
((!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line)) ||
// Bracketed prompt start: [ hostname:/path or [ user@host:/path
// e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test"
// e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte"
((!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line)) ||
// Wrapped continuation: user@host:path or hostname:path (no trailing $)
// Only matched after we've already stripped a prompt fragment below.
// e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn"
((!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line)) ||
// Bracketed prompt end: ...] $ or ...] #
// e.g., "s/testWorkspace (main**) ] $ "
((!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line));
if (isCompletePrompt) {
endIndex--;
trailingStrippedCount++;
break; // Complete prompt = nothing above can be prompt wrap
} else if (isPromptFragment) {
endIndex--;
trailingStrippedCount++;
} else {
break;
}

View File

@@ -457,4 +457,108 @@ suite('stripCommandEchoAndPrompt', () => {
''
);
});
// --- Adversarial tests: output that looks like prompts ---
// These verify that realistic output is NOT falsely stripped.
suite('adversarial: output resembling prompts', () => {
test('output ending with $ is preserved (not confused with wrapped prompt)', () => {
const output = [
'user@host:~ $ echo \'test$\'',
'test$',
'user@host:~ $',
].join('\n');
// 'user@host:~ $' is a complete prompt → stripped and loop stops.
// 'test$' is preserved because nothing above a complete prompt is stripped.
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'echo \'test$\''),
'test$'
);
});
test('output ending with # is preserved (not confused with wrapped prompt)', () => {
const output = [
'user@host:~ $ echo \'div#\'',
'div#',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'echo \'div#\''),
'div#'
);
});
test('bracketed log output [tag:~/path] is preserved', () => {
const output = [
'user@host:~ $ node build.js',
'[build:~/dist] compiled successfully',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'node build.js'),
'[build:~/dist] compiled successfully'
);
});
test('output containing user@host:path ending with # is preserved', () => {
const output = [
'user@host:~ $ cat /etc/motd',
'admin@server:~/docs #',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'cat /etc/motd'),
'admin@server:~/docs #'
);
});
test('output ending with ] $ is preserved', () => {
const output = [
'user@host:~ $ echo \'values: [a, b] $\'',
'values: [a, b] $',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'echo \'values: [a, b] $\''),
'values: [a, b] $'
);
});
test('multiple prompt-like output lines are all preserved', () => {
// Complete prompt at the bottom stops stripping immediately,
// so all prompt-like output lines above are preserved.
const output = [
'user@host:~ $ cat prompts.txt',
'admin@server:~/docs $',
'root@box:/var/log #',
'test@dev:~ $',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, 'cat prompts.txt'),
'admin@server:~/docs $\nroot@box:/var/log #\ntest@dev:~ $'
);
});
test('multi-line output where last line has $ after non-word chars is preserved', () => {
const output = [
'user@host:~ $ ./report.sh',
'Revenue: 1000',
'Currency: USD$',
'user@host:~ $',
].join('\n');
assert.strictEqual(
stripCommandEchoAndPrompt(output, './report.sh'),
'Revenue: 1000\nCurrency: USD$'
);
});
});
});