mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
feat(gateguard): profile shell scripts and Python module constants
This commit is contained in:
@@ -286,8 +286,9 @@ extension, a non-string side, no entries or more than 64, a side over 64 KiB
|
||||
(UTF-8), more than 256 KiB in total, or any exception. Supported languages, by
|
||||
extension: JS/TS (`.js .mjs .cjs .jsx .ts .tsx .mts .cts`), Python
|
||||
(`.py .pyi`), Go, Rust, Java, Kotlin (`.kt .kts`), C# and C/C++ (`.c .h .cc
|
||||
.cpp .cxx .hpp .hh .hxx`). Shell, Ruby, Makefiles, Swift and everything else
|
||||
are unknown.
|
||||
.cpp .cxx .hpp .hh .hxx`), and shell scripts: POSIX shells (`.sh .bash .zsh`),
|
||||
PowerShell (`.ps1 .psm1`) and batch (`.bat .cmd`). Ruby, Makefiles, Swift,
|
||||
fish, PowerShell data files and everything else are unknown.
|
||||
|
||||
## Public surface
|
||||
|
||||
@@ -301,16 +302,26 @@ around a public declaration means the edit is inside that symbol.
|
||||
`.d.mts` and `.d.cts` files are all surface.
|
||||
- Python: `def`, `async def` or `class` whose name does not start with `_`
|
||||
(dunders such as `__init__` are public), any `__all__`, and a column-0
|
||||
assignment or annotation of a public name on a line after the first (the
|
||||
first line of a snippet may start mid-line). `__init__.py` is all surface.
|
||||
assignment or annotation of a public name (a module constant or variable
|
||||
that importers can read). The first line of a snippet may start mid-line
|
||||
(an Edit's `old_string` can begin after the indentation), so there only an
|
||||
all-uppercase name counts, the constant convention that function locals
|
||||
rarely use. `__init__.py` is all surface.
|
||||
- Go: `func` (after an optional receiver), `type`, `var` or `const` with a
|
||||
capitalised name, a `package` line, and any line that starts with a
|
||||
capital letter (exported fields, interface methods, grouped declarations).
|
||||
- Rust: lines starting with `pub` (any `pub(...)`), `impl`, `trait`, or
|
||||
`#[macro_export`.
|
||||
- Java, Kotlin, C#, C and C++ always touch the surface: package-private Java,
|
||||
Kotlin's public default, C# partial classes and C linkage make a lexical
|
||||
answer unreliable.
|
||||
- POSIX shells: a function definition (`name() {`, `function name`), an
|
||||
`export`, or `declare -x`/`typeset -x`, at any indentation. A sourced
|
||||
script's functions and exported variables are what other scripts use.
|
||||
- PowerShell (case-insensitive): lines starting with `function`, `filter`,
|
||||
`workflow`, `class`, `enum`, `param` (a script's parameters are its
|
||||
interface), `Export-ModuleMember`, `[CmdletBinding` or `$global:`.
|
||||
- Java, Kotlin, C#, C, C++ and batch files always touch the surface:
|
||||
package-private Java, Kotlin's public default, C# partial classes, C linkage
|
||||
and batch labels reachable by `call :label` make a lexical answer
|
||||
unreliable.
|
||||
|
||||
An Edit that changes a function body without its declaration line in the
|
||||
snippet reads as not touching the surface; the local question still asks for
|
||||
@@ -329,6 +340,16 @@ for `open(`, for adjacent pairs such as `read file`,
|
||||
(`select`+`from`, `insert`+`into`, `create`+`table`, ...). Over-matching only
|
||||
keeps the data question.
|
||||
|
||||
Shell scripts also touch data with a file redirection (`>`, `>>`, `>|`, `&>`
|
||||
or `<` followed by a target other than `/dev/null`, `/dev/stdout`,
|
||||
`/dev/stderr`, `$null` or `nul`; descriptor duplication such as `2>&1` and
|
||||
`>&2`, heredocs and process substitution do not count), an HTTP or document
|
||||
tool (`curl`, `wget`, `jq`, `yq`, `xmllint`, `psql`, `tee`, `iwr`, `irm`),
|
||||
or a PowerShell file or web cmdlet (`Out-File`, `Get-Content`,
|
||||
`Set-Content`, `Add-Content`, `Invoke-WebRequest`, `Invoke-RestMethod`,
|
||||
`*-Clixml`; `Import-Csv` matches `csv`). Redirection is found without
|
||||
reading quotes, so a `>` inside a string also counts.
|
||||
|
||||
## Trivial edits
|
||||
|
||||
An Edit is trivial when, for every entry, old and new have the same code once
|
||||
@@ -353,6 +374,32 @@ Anything the lexer cannot read with confidence makes the entry non-trivial:
|
||||
Make), the trigraph `??/`, whitespace after a line-continuation `\`, a `/*`
|
||||
inside a block comment, and an unterminated block comment or string.
|
||||
|
||||
## Shell scripts
|
||||
|
||||
Shell comments are lexed by their own rules, and the lexer gives up (the entry
|
||||
is not trivial) wherever a comment could be code:
|
||||
|
||||
- POSIX shells: `#` starts a comment only at the start of a line or after a
|
||||
space, tab or `;`; after a letter, `$`, `{`, `=` and the like it is a word
|
||||
character (`a#b`, `$#`, `${#x}`, `${x#y}`). After `(`, `)`, `|`, `&`, `<` or
|
||||
`>` the entry is not trivial (zsh glob flags such as `(#i)`). Single quotes
|
||||
are raw and must close on the line; `$'...'` is not trivial; double quotes
|
||||
may hold `\` escapes and a `${...}` without quotes, but `$(` inside them,
|
||||
any backtick, any `<<` (heredocs, here-strings, shifts), a `#!` line, a
|
||||
backslash before a line break, and a lone carriage return (bash reads it
|
||||
as a word character) make the entry not trivial. Only spaces and tabs
|
||||
between words are folded; `\` escapes stay code.
|
||||
- PowerShell: `#` and `<# ... #>` are comments only at the start of a line or
|
||||
after a space, tab or `;`; elsewhere the entry is not trivial. `#Requires`
|
||||
is a directive and never a comment. Here-strings (`@"`, `@'`), typographic
|
||||
quotes (PowerShell accepts them as quote marks), `$(` inside a double-quoted
|
||||
string, a backtick before a line break, and a nested `<#` are not trivial.
|
||||
- Batch: only `REM` lines (after optional blanks and `@`, followed by a blank
|
||||
or the end of the line) are comments; `::` labels are code, since they
|
||||
change the parser's behaviour inside blocks. A `REM` line holding `%`, `^`,
|
||||
`&`, `|`, `<`, `>`, `(` or `)`, and any line ending in `^`, are not trivial.
|
||||
Code lines are compared exactly: `echo` keeps its spacing.
|
||||
|
||||
Write is never trivial. In the hook, a trivial Edit (or a MultiEdit whose
|
||||
entries for that file are all trivial) of an unchecked code, test or prose
|
||||
target passes with an `additionalContext` note after prior-search credit and
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,7 +17,10 @@ const LANGUAGE_BY_EXT = new Map([
|
||||
['.kt', 'kotlin'], ['.kts', 'kotlin'],
|
||||
['.cs', 'csharp'],
|
||||
['.c', 'c'], ['.h', 'c'],
|
||||
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp']
|
||||
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp'],
|
||||
['.sh', 'shell'], ['.bash', 'shell'], ['.zsh', 'shell'],
|
||||
['.ps1', 'powershell'], ['.psm1', 'powershell'],
|
||||
['.bat', 'batch'], ['.cmd', 'batch']
|
||||
]);
|
||||
|
||||
// --- Lexing ---
|
||||
@@ -34,7 +37,10 @@ const LEX_SPECS = {
|
||||
csharp: { ...C_FAMILY, tripleQuotes: true, rawPrefixes: '@$' },
|
||||
c: { ...C_FAMILY, rawPrefixes: 'R' },
|
||||
cpp: { ...C_FAMILY, rawPrefixes: 'R' },
|
||||
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true }
|
||||
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true },
|
||||
shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\') },
|
||||
powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`') },
|
||||
batch: { lexer: batchCodeLines, normalize: line => line }
|
||||
};
|
||||
|
||||
function isSpace(ch) {
|
||||
@@ -205,12 +211,13 @@ function hasSpaceAfterContinuation(line) {
|
||||
}
|
||||
|
||||
function codeSignature(text, spec) {
|
||||
const lines = lexCodeLines(text, spec);
|
||||
const lines = spec.lexer ? spec.lexer(text) : lexCodeLines(text, spec);
|
||||
if (lines === null) return null;
|
||||
const signature = [];
|
||||
for (const line of lines) {
|
||||
if (hasSpaceAfterContinuation(line)) return null;
|
||||
const normalized = normalizeCodeLine(line, spec);
|
||||
const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec);
|
||||
if (normalized === null) return null;
|
||||
if (normalized) signature.push(normalized);
|
||||
}
|
||||
return signature;
|
||||
@@ -225,6 +232,230 @@ function isTrivialEdit(oldString, newString, spec) {
|
||||
return before.every((line, index) => line === after[index]);
|
||||
}
|
||||
|
||||
// --- Shell lexing ---
|
||||
// see docs/gateguard/design-notes.md#shell-scripts
|
||||
|
||||
function isLineStart(text, i) {
|
||||
return i === 0 || text[i - 1] === '\n' || text[i - 1] === '\r';
|
||||
}
|
||||
|
||||
function hashStartsComment(text, i) {
|
||||
if (isLineStart(text, i)) return true;
|
||||
const prev = text[i - 1];
|
||||
if (prev === ' ' || prev === '\t' || prev === ';') return true;
|
||||
return '()|&<>'.includes(prev) ? null : false;
|
||||
}
|
||||
|
||||
function shDoubleQuoteEnd(text, i) {
|
||||
for (let j = i + 1; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (ch === '"') return j;
|
||||
if (ch === '\n' || ch === '\r' || ch === '`') return -1;
|
||||
if (ch === '\\') {
|
||||
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
|
||||
j++;
|
||||
} else if (ch === '$' && text[j + 1] === '(') {
|
||||
return -1;
|
||||
} else if (ch === '$' && text[j + 1] === '{') {
|
||||
const close = text.indexOf('}', j + 2);
|
||||
if (close === -1) return -1;
|
||||
for (let k = j + 2; k < close; k++) {
|
||||
if ('"\'\n\r'.includes(text[k])) return -1;
|
||||
}
|
||||
j = close;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function hasLoneCarriageReturn(text) {
|
||||
for (let at = text.indexOf('\r'); at !== -1; at = text.indexOf('\r', at + 1)) {
|
||||
if (text[at + 1] !== '\n') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBlank(ch) {
|
||||
return ch === ' ' || ch === '\t';
|
||||
}
|
||||
|
||||
function shCodeLines(text) {
|
||||
if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null;
|
||||
const lines = [];
|
||||
let code = '';
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '\n' || ch === '\r') {
|
||||
lines.push(code);
|
||||
code = '';
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
const next = text[i + 1];
|
||||
if (next === undefined || next === '\n' || next === '\r') return null;
|
||||
code += ch + next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
if (text[i - 1] === '$') return null;
|
||||
const end = text.indexOf("'", i + 1);
|
||||
if (end === -1) return null;
|
||||
const body = text.slice(i, end + 1);
|
||||
if (body.includes('\n') || body.includes('\r')) return null;
|
||||
code += body;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
const end = shDoubleQuoteEnd(text, i);
|
||||
if (end === -1) return null;
|
||||
code += text.slice(i, end + 1);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#') {
|
||||
const starts = hashStartsComment(text, i);
|
||||
if (starts === null) return null;
|
||||
if (starts) {
|
||||
if (isLineStart(text, i) && text[i + 1] === '!') return null;
|
||||
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
code += ch;
|
||||
}
|
||||
lines.push(code);
|
||||
return lines;
|
||||
}
|
||||
|
||||
const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/;
|
||||
|
||||
function psHashStartsComment(text, i) {
|
||||
if (isLineStart(text, i)) return true;
|
||||
const prev = text[i - 1];
|
||||
return prev === ' ' || prev === '\t' || prev === ';';
|
||||
}
|
||||
|
||||
function psQuoteEnd(text, i) {
|
||||
const quote = text[i];
|
||||
for (let j = i + 1; j < text.length; j++) {
|
||||
const ch = text[j];
|
||||
if (ch === '\n' || ch === '\r') return -1;
|
||||
if (quote === '"' && ch === '`') {
|
||||
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
|
||||
j++;
|
||||
} else if (quote === '"' && ch === '$' && text[j + 1] === '(') {
|
||||
return -1;
|
||||
} else if (ch === quote) {
|
||||
if (text[j + 1] !== quote) return j;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function psCodeLines(text) {
|
||||
if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null;
|
||||
const lines = [];
|
||||
let code = '';
|
||||
let block = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '\n' || ch === '\r') {
|
||||
lines.push(code);
|
||||
code = '';
|
||||
if (ch === '\r' && text[i + 1] === '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (block) {
|
||||
if (ch === '<' && text[i + 1] === '#') return null;
|
||||
if (ch === '#' && text[i + 1] === '>') {
|
||||
block = false;
|
||||
code += ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '`') {
|
||||
const next = text[i + 1];
|
||||
if (next === undefined || next === '\n' || next === '\r') return null;
|
||||
code += ch + next;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const end = psQuoteEnd(text, i);
|
||||
if (end === -1) return null;
|
||||
code += text.slice(i, end + 1);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '<' && text[i + 1] === '#') {
|
||||
if (!psHashStartsComment(text, i)) return null;
|
||||
block = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#') {
|
||||
if (!psHashStartsComment(text, i)) return null;
|
||||
if (text.slice(i + 1, i + 9).toLowerCase() === 'requires') return null;
|
||||
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
|
||||
continue;
|
||||
}
|
||||
code += ch;
|
||||
}
|
||||
if (block) return null;
|
||||
lines.push(code);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function batchCodeLines(text) {
|
||||
const lines = [];
|
||||
for (const line of text.split(/\r\n|\r|\n/)) {
|
||||
if (line.endsWith('^')) return null;
|
||||
let start = 0;
|
||||
while (isBlank(line[start])) start++;
|
||||
if (line[start] === '@') start++;
|
||||
while (isBlank(line[start])) start++;
|
||||
const word = line.slice(start, start + 3).toLowerCase();
|
||||
const after = line[start + 3];
|
||||
if (word === 'rem' && (after === undefined || isBlank(after))) {
|
||||
if (/[%^&|<>()]/.test(line)) return null;
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push(line.trim() ? line : '');
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function normalizeShellLine(line, escape) {
|
||||
let out = '';
|
||||
let pendingSpace = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (isBlank(ch)) {
|
||||
pendingSpace = true;
|
||||
continue;
|
||||
}
|
||||
if (pendingSpace && out) out += ' ';
|
||||
pendingSpace = false;
|
||||
if (ch === escape && i + 1 < line.length) {
|
||||
out += ch + line[++i];
|
||||
} else if (ch === "'" || ch === '"') {
|
||||
const end = escape === '`' ? psQuoteEnd(line, i) : ch === "'" ? line.indexOf("'", i + 1) : shDoubleQuoteEnd(line, i);
|
||||
if (end === -1) return null;
|
||||
out += line.slice(i, end + 1);
|
||||
i = end;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Public surface ---
|
||||
|
||||
function leadingWord(text, word) {
|
||||
@@ -274,14 +505,13 @@ function pythonLineIsSurface(line, index) {
|
||||
const rest = t.slice(skipSpaces(t, 5));
|
||||
if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3)));
|
||||
}
|
||||
if (index > 0 && start === 0) {
|
||||
const name = identifierAt(t, 0);
|
||||
const after = t[skipSpaces(t, name.length)];
|
||||
const rest = skipSpaces(t, name.length);
|
||||
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
|
||||
return Boolean(name) && isLetter(name[0]) && assigned && pythonNameIsPublic(name);
|
||||
}
|
||||
return false;
|
||||
if (start !== 0) return false;
|
||||
const name = identifierAt(t, 0);
|
||||
const rest = skipSpaces(t, name.length);
|
||||
const after = t[rest];
|
||||
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
|
||||
if (!name || !isLetter(name[0]) || !assigned) return false;
|
||||
return index > 0 ? pythonNameIsPublic(name) : name === name.toUpperCase();
|
||||
}
|
||||
|
||||
function goLineIsSurface(line) {
|
||||
@@ -306,11 +536,38 @@ function rustLineIsSurface(line) {
|
||||
return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || t.startsWith('#[macro_export');
|
||||
}
|
||||
|
||||
function shellFunctionName(t) {
|
||||
let end = 0;
|
||||
while (end < t.length && (isIdentChar(t[end]) || t[end] === '-' || t[end] === ':' || t[end] === '.')) end++;
|
||||
if (end === 0) return false;
|
||||
const open = skipSpaces(t, end);
|
||||
return t[open] === '(' && t[skipSpaces(t, open + 1)] === ')';
|
||||
}
|
||||
|
||||
function shellLineIsSurface(line) {
|
||||
const t = line.slice(skipSpaces(line, 0));
|
||||
if (leadingWord(t, 'export') || leadingWord(t, 'function')) return true;
|
||||
if (leadingWord(t, 'declare') || leadingWord(t, 'typeset')) {
|
||||
const flags = t.slice(skipSpaces(t, 7));
|
||||
return flags[0] === '-' && identifierAt(flags, 1).includes('x');
|
||||
}
|
||||
return shellFunctionName(t);
|
||||
}
|
||||
|
||||
const POWERSHELL_SURFACE_WORDS = ['function', 'filter', 'workflow', 'class', 'enum', 'param', 'export-modulemember', '[cmdletbinding', '$global:'];
|
||||
|
||||
function powershellLineIsSurface(line) {
|
||||
const t = line.slice(skipSpaces(line, 0)).toLowerCase();
|
||||
return POWERSHELL_SURFACE_WORDS.some(word => t.startsWith(word) && (word.endsWith(':') || !isIdentChar(t[word.length])));
|
||||
}
|
||||
|
||||
const SURFACE_BY_LANGUAGE = {
|
||||
js: jsLineIsSurface,
|
||||
python: pythonLineIsSurface,
|
||||
go: goLineIsSurface,
|
||||
rust: rustLineIsSurface
|
||||
rust: rustLineIsSurface,
|
||||
shell: shellLineIsSurface,
|
||||
powershell: powershellLineIsSurface
|
||||
};
|
||||
|
||||
function baseName(filePath) {
|
||||
@@ -375,11 +632,46 @@ function* words(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function textTouchesData(text) {
|
||||
const SHELL_LANGUAGES = new Set(['shell', 'powershell', 'batch']);
|
||||
const SHELL_DATA_WORDS = new Set(['curl', 'wget', 'jq', 'yq', 'xmllint', 'psql', 'tee', 'iwr', 'irm', 'clixml']);
|
||||
const SHELL_DATA_PAIRS = [['out', 'file'], ['get', 'content'], ['set', 'content'], ['add', 'content'], ['web', 'request'], ['rest', 'method']];
|
||||
const NULL_TARGETS = ['/dev/null', '/dev/stdout', '/dev/stderr', '$null', 'nul'];
|
||||
|
||||
function redirectTargetIsFile(text, at) {
|
||||
const start = skipSpaces(text, at);
|
||||
const ch = text[start];
|
||||
if (ch === undefined || ch === '\n' || ch === '\r' || ch === '&') return false;
|
||||
const rest = text.slice(start, start + 12).toLowerCase();
|
||||
return !NULL_TARGETS.some(target => rest.startsWith(target) && !isIdentChar(rest[target.length]) && rest[target.length] !== '.');
|
||||
}
|
||||
|
||||
function shellRedirectsFile(text) {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '>') {
|
||||
const prev = text[i - 1];
|
||||
if (prev === '-' || prev === '=' || prev === '>') continue;
|
||||
let at = i + 1;
|
||||
if (text[at] === '>' || text[at] === '|') at++;
|
||||
if (text[at] === '=' || text[at] === '&') continue;
|
||||
if (redirectTargetIsFile(text, at)) return true;
|
||||
} else if (ch === '<') {
|
||||
const next = text[i + 1];
|
||||
if (text[i - 1] === '<' || next === '<' || next === '(' || next === '&' || next === '#' || next === '=') continue;
|
||||
if (redirectTargetIsFile(text, i + 1)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function textTouchesData(text, language) {
|
||||
const shell = SHELL_LANGUAGES.has(language);
|
||||
if (shell && shellRedirectsFile(text)) return true;
|
||||
const seen = new Set();
|
||||
let previous = '';
|
||||
for (const { word, call } of words(text)) {
|
||||
if (DATA_WORDS.has(word)) return true;
|
||||
if (shell && (SHELL_DATA_WORDS.has(word) || SHELL_DATA_PAIRS.some(([a, b]) => previous === a && word === b))) return true;
|
||||
if (word === 'open' && call) return true;
|
||||
if (DATA_PAIRS.some(([a, b]) => previous === a && word === b)) return true;
|
||||
seen.add(word);
|
||||
@@ -438,7 +730,7 @@ function profileChange(input) {
|
||||
const lineIsSurface = SURFACE_BY_LANGUAGE[language];
|
||||
const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) ||
|
||||
texts.sides.some(side => textTouchesSurface(side, lineIsSurface));
|
||||
const touchesData = texts.sides.some(textTouchesData);
|
||||
const touchesData = texts.sides.some(side => textTouchesData(side, language));
|
||||
const spec = LEX_SPECS[language];
|
||||
const trivial = input.tool === 'Edit' && texts.pairs.every(([oldString, newString]) => isTrivialEdit(oldString, newString, spec));
|
||||
return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial });
|
||||
|
||||
@@ -95,19 +95,24 @@ For **code** targets the gate reads the change it is about to allow (Edit
|
||||
- **Public surface:** a line on either side declares or exposes a public name.
|
||||
JS/TS: `export`, `exports`, `public`, `declare` (`.d.ts` files always); Python:
|
||||
`def`/`class` not starting with `_`, dunders, `__all__`, a module-level public
|
||||
assignment (`__init__.py` always); Go: capitalised `func`/`type`/`var`/`const`
|
||||
names, `package`, lines starting with a capital; Rust: `pub`, `impl`, `trait`,
|
||||
`#[macro_export]`. Java, Kotlin, C#, C and C++, and every Write, always count
|
||||
as touching it. Touching it keeps questions 1 and 2; otherwise both are
|
||||
assignment such as `BASE_URL = ...` (`__init__.py` always); Go: capitalised
|
||||
`func`/`type`/`var`/`const` names, `package`, lines starting with a capital;
|
||||
Rust: `pub`, `impl`, `trait`, `#[macro_export]`; shell: function definitions,
|
||||
`export`, `declare -x`; PowerShell: `function`, `filter`, `param`,
|
||||
`Export-ModuleMember`, `[CmdletBinding`. Java, Kotlin, C#, C, C++ and batch
|
||||
files, and every Write, always count as touching it. Touching it keeps questions 1 and 2; otherwise both are
|
||||
replaced by "List the call sites in this file or its module that rely on the
|
||||
changed behaviour".
|
||||
- **Data:** words for file I/O, formats (`json`, `csv`, `yaml`, …),
|
||||
serialisation, schemas, SQL and dates on either side. Without them the data
|
||||
serialisation, schemas, SQL and dates on either side; in shell scripts also
|
||||
redirection to a file, `curl`, `wget`, `jq` and PowerShell file and web
|
||||
cmdlets. Without them the data
|
||||
question is dropped.
|
||||
- **Trivial:** see [Comment and whitespace-only edits](#comment-and-whitespace-only-edits).
|
||||
|
||||
Supported extensions: `.js .mjs .cjs .jsx .ts .tsx .mts .cts .py .pyi .go .rs
|
||||
.java .kt .kts .cs .c .h .cc .cpp .cxx .hpp .hh .hxx`. Any other extension, a
|
||||
.java .kt .kts .cs .c .h .cc .cpp .cxx .hpp .hh .hxx .sh .bash .zsh .ps1 .psm1
|
||||
.bat .cmd`. Any other extension, a
|
||||
missing or non-string field, more than 64 MultiEdit entries, a side over
|
||||
64 KiB, or 256 KiB in total means the full questions, word for word. Sensitive
|
||||
targets always get the full questions. Other classes keep their class
|
||||
@@ -404,8 +409,10 @@ It applies only to the extensions listed in
|
||||
[Questions fit the change](#questions-fit-the-change), and only when every
|
||||
entry reads cleanly:
|
||||
|
||||
- comments are `//` and `/* */` (C family) or `#` (Python); C preprocessor
|
||||
lines are code; whitespace inside strings, line breaks between code, and
|
||||
- comments are `//` and `/* */` (C family), `#` (Python), `#` at the start of
|
||||
a word (shell), `#` and `<# #>` (PowerShell, not `#Requires`), or `REM`
|
||||
lines (batch); C preprocessor lines, shebang lines and batch `::` labels
|
||||
are code; whitespace inside strings, line breaks between code, and
|
||||
the presence of whitespace between tokens are code (`a+b` → `a + b` is not
|
||||
trivial);
|
||||
- Python indentation of code lines is code;
|
||||
@@ -413,7 +420,9 @@ entry reads cleanly:
|
||||
quotes, `r"`, `R"`, `@"`, `$"`, f-strings, Kotlin `$`), a string running to
|
||||
the end of a line, a line comment ending in `\`, `??/`, a nested or
|
||||
unterminated block comment; in JS/TS any `/` outside a comment (regex versus
|
||||
division), JSX-like tags, and `-->`;
|
||||
division), JSX-like tags, and `-->`; in shell scripts heredocs, backticks,
|
||||
`$(` inside double quotes, `$'...'`, line continuations and here-strings
|
||||
(see the design notes for the full list);
|
||||
- config, instruction, and sensitive targets never pass this way.
|
||||
|
||||
Only the snippet is read, so a comment-looking line inside a multi-line string
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
},
|
||||
{
|
||||
"id": "unknown-extension-comment",
|
||||
"note": "Shell has no change profile, so it is never trivial.",
|
||||
"note": "A comment-only change to a shell script; # starts a comment only at the start of a word.",
|
||||
"transcript": [
|
||||
{
|
||||
"type": "user",
|
||||
@@ -402,9 +402,9 @@
|
||||
},
|
||||
"tool_use_id": "toolu_commentonlyedits_006"
|
||||
},
|
||||
"expect": "deny",
|
||||
"expect": "allow",
|
||||
"mustDeny": false,
|
||||
"redundant": false,
|
||||
"redundant": true,
|
||||
"relevantQuestions": [
|
||||
"quote-instruction"
|
||||
]
|
||||
|
||||
@@ -4552,6 +4552,44 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('shell scripts ask the questions their change warrants', () => {
|
||||
const local = b2Reason('Edit', { file_path: 'scripts/q1.sh', old_string: ' echo "a"', new_string: ' echo "b"' });
|
||||
assert.deepStrictEqual(b2Questions(local), [B2_TEXT.localCallers, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const fn = b2Reason('Edit', { file_path: 'scripts/q2.sh', old_string: 'deploy() {', new_string: 'deploy_all() {' });
|
||||
assert.deepStrictEqual(b2Questions(fn), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const exported = b2Reason('Edit', { file_path: 'scripts/q3.sh', old_string: 'export MODE=a', new_string: 'export MODE=b' });
|
||||
assert.deepStrictEqual(b2Questions(exported), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const fetch = b2Reason('Edit', { file_path: 'scripts/q4.sh', old_string: ' run', new_string: ' curl -s "$URL" > out.json' });
|
||||
assert.deepStrictEqual(b2Questions(fetch), [B2_TEXT.localCallers, B2_TEXT.dataSchema, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const ps = b2Reason('Edit', { file_path: 'scripts/q5.ps1', old_string: 'function Get-A {', new_string: 'function Get-B {' });
|
||||
assert.deepStrictEqual(b2Questions(ps), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const bat = b2Reason('Edit', { file_path: 'scripts/q6.bat', old_string: 'echo a', new_string: 'echo b' });
|
||||
assert.deepStrictEqual(b2Questions(bat), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote], 'batch always counts as surface');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('a one-line edit of a Python module constant asks for importers', () => {
|
||||
const reason = b2Reason('Edit', { file_path: 'tools/q7.py', old_string: 'BASE = "https://a.test/#home"', new_string: 'BASE = "https://a.test/#admin"' });
|
||||
assert.deepStrictEqual(b2Questions(reason), [B2_TEXT.importers, B2_TEXT.publicApi, B2_TEXT.quote]);
|
||||
clearState();
|
||||
const local = b2Reason('Edit', { file_path: 'tools/q8.py', old_string: 'result = compute(a)', new_string: 'result = compute(b)' });
|
||||
assert.deepStrictEqual(b2Questions(local), [B2_TEXT.localCallers, B2_TEXT.quote]);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('condensed denials follow the change profile', () => {
|
||||
@@ -4626,6 +4664,26 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('comment-only edits of shell, PowerShell and batch scripts pass without marking them checked', () => {
|
||||
const cases = [
|
||||
{ file_path: 'scripts/s1.sh', old_string: '# build\n', new_string: '# Build all targets.\n' },
|
||||
{ file_path: 'scripts/s2.ps1', old_string: '<# old #>\nGet-Item a', new_string: '<# new #>\nGet-Item a' },
|
||||
{ file_path: 'scripts/s3.cmd', old_string: 'REM old\necho a', new_string: 'REM new\necho a' }
|
||||
];
|
||||
for (const input of cases) {
|
||||
const out = b3Run('Edit', input);
|
||||
assert.notStrictEqual(out.decision, 'deny', out.result.stdout);
|
||||
assert.ok(out.context.includes(`${B3_NOTE} ${input.file_path}`), out.context);
|
||||
assert.ok(!(b3State().checked || []).includes(b3Key(input.file_path)), 'not marked checked');
|
||||
}
|
||||
assert.strictEqual(b3State().trivial_allows, 3);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('trivial-looking edits are still denied where the pass does not apply', () => {
|
||||
@@ -4639,7 +4697,14 @@ function runTests() {
|
||||
['instruction code file', { file_path: '.claude/hooks/d5.md', old_string: '// old', new_string: '// new' }],
|
||||
['template literal', { file_path: 'src/d6.js', old_string: '// old\n`;', new_string: '// new\n`;' }],
|
||||
['over the bound', { file_path: 'src/d7.js', old_string: `// ${'x'.repeat(70 * 1024)}`, new_string: '// y' }],
|
||||
['missing new_string', { file_path: 'src/d8.js', old_string: '// old' }]
|
||||
['missing new_string', { file_path: 'src/d8.js', old_string: '// old' }],
|
||||
['sensitive shell script', { file_path: 'scripts/auth/rotate.sh', old_string: '# old\nrun', new_string: '# new\nrun' }],
|
||||
['workflow shell script', { file_path: '.github/workflows/d9.sh', old_string: '# old', new_string: '# new' }],
|
||||
['shell heredoc', { file_path: 'scripts/d10.sh', old_string: 'cat <<EOF\n# old\nEOF', new_string: 'cat <<EOF\n# new\nEOF' }],
|
||||
['shell quoted hash', { file_path: 'scripts/d11.sh', old_string: 'echo "a # old"', new_string: 'echo "a # new"' }],
|
||||
['shell shebang', { file_path: 'scripts/d12.sh', old_string: '#!/bin/bash', new_string: '#!/bin/sh' }],
|
||||
['powershell requires', { file_path: 'scripts/d13.ps1', old_string: '#Requires -Version 5', new_string: '#Requires -Version 7' }],
|
||||
['powershell here-string', { file_path: 'scripts/d14.ps1', old_string: '@"\n# old\n"@', new_string: '@"\n# new\n"@' }]
|
||||
];
|
||||
for (const [label, input] of cases) {
|
||||
clearState();
|
||||
|
||||
@@ -42,10 +42,11 @@ test('supported extensions map to a language; others do not', () => {
|
||||
'a.js': 'js', 'a.mjs': 'js', 'a.cjs': 'js', 'a.jsx': 'js', 'a.ts': 'js', 'a.tsx': 'js', 'a.mts': 'js', 'a.cts': 'js',
|
||||
'a.py': 'python', 'a.pyi': 'python', 'a.go': 'go', 'a.rs': 'rust', 'a.java': 'java', 'a.kt': 'kotlin', 'a.kts': 'kotlin',
|
||||
'a.cs': 'csharp', 'a.c': 'c', 'a.h': 'c', 'a.cc': 'cpp', 'a.cpp': 'cpp', 'a.cxx': 'cpp', 'a.hpp': 'cpp', 'a.hh': 'cpp',
|
||||
'SRC\\A.PY': 'python'
|
||||
'SRC\\A.PY': 'python', 'a.sh': 'shell', 'a.bash': 'shell', 'a.zsh': 'shell', 'a.ps1': 'powershell', 'a.psm1': 'powershell',
|
||||
'a.bat': 'batch', 'a.cmd': 'batch', 'B.CMD': 'batch'
|
||||
};
|
||||
for (const [file, lang] of Object.entries(expected)) assert.strictEqual(languageFor(file), lang, file);
|
||||
for (const file of ['a.rb', 'a.sh', 'Makefile', 'a.mk', 'a.yaml', 'a.md', 'a', 'a.swift', '', null]) {
|
||||
for (const file of ['a.rb', 'a.fish', 'a.psd1', 'Makefile', 'a.mk', 'a.yaml', 'a.md', 'a', 'a.swift', '', null]) {
|
||||
assert.strictEqual(languageFor(file), null, String(file));
|
||||
}
|
||||
});
|
||||
@@ -110,6 +111,21 @@ test('Python: public def/class, dunders, __all__ and __init__.py touch the surfa
|
||||
assert.strictEqual(surface('a.py', 'x = 1\nLIMIT = 3', 'x = 1\nLIMIT = 4'), true, 'module-level public name');
|
||||
});
|
||||
|
||||
test('Python: a column-0 constant touches the surface even on the first line of a snippet', () => {
|
||||
assert.strictEqual(surface('a.py', 'BASE = "https://a.test/#home"', 'BASE = "https://a.test/#admin"'), true);
|
||||
assert.strictEqual(surface('a.py', 'MAX_RETRIES = 3', 'MAX_RETRIES = 4'), true);
|
||||
assert.strictEqual(surface('a.py', 'TIMEOUT: float = 1.5', 'TIMEOUT: float = 2.5'), true);
|
||||
assert.strictEqual(surface('a.py', 'x = 1\nretries = 3', 'x = 1\nretries = 4'), true, 'public name after the first line');
|
||||
});
|
||||
|
||||
test('Python: private, indented, lowercase first-line and comparison lines stay local', () => {
|
||||
assert.strictEqual(surface('a.py', '_BASE = 1', '_BASE = 2'), false, 'private constant');
|
||||
assert.strictEqual(surface('a.py', ' LIMIT = 3', ' LIMIT = 4'), false, 'indented');
|
||||
assert.strictEqual(surface('a.py', 'result = compute(a)', 'result = compute(b)'), false, 'first line may start mid-line');
|
||||
assert.strictEqual(surface('a.py', 'LIMIT == 3', 'LIMIT == 4'), false, 'comparison');
|
||||
assert.strictEqual(surface('a.py', 'X1 = 1', 'X1 = 2'), true, 'digits allowed after the first letter');
|
||||
});
|
||||
|
||||
test('Python: private definitions and bodies do not touch the surface', () => {
|
||||
assert.strictEqual(surface('a.py', 'def _helper(a):', 'def _helper(a, b):'), false);
|
||||
assert.strictEqual(surface('a.py', ' return a + 1', ' return a + 2'), false);
|
||||
@@ -152,6 +168,150 @@ test('Write always touches the surface', () => {
|
||||
assert.strictEqual(profile.trivial, false);
|
||||
});
|
||||
|
||||
// ── shell scripts ──
|
||||
console.log('\nshell scripts:');
|
||||
|
||||
test('shell: function definitions and exported variables touch the surface', () => {
|
||||
const hits = [
|
||||
['a.sh', 'deploy() {', 'deploy() {\n set -e'],
|
||||
['a.sh', 'function deploy {', 'function deploy_all {'],
|
||||
['a.bash', 'function deploy() {', 'function deploy(){'],
|
||||
['a.zsh', 'my-helper () {', 'my-helper () { :'],
|
||||
['a.sh', 'export PATH="$HOME/bin:$PATH"', 'export PATH="$HOME/.local/bin:$PATH"'],
|
||||
['a.sh', 'declare -x MODE=a', 'declare -x MODE=b'],
|
||||
['a.ps1', 'function Get-Item2 {', 'function Get-Item2 { param($x)'],
|
||||
['a.psm1', 'Export-ModuleMember -Function Get-A', 'Export-ModuleMember -Function Get-A, Get-B'],
|
||||
['a.ps1', 'param([string]$Path)', 'param([string]$Path, [switch]$Force)'],
|
||||
['a.ps1', '[CmdletBinding()]', '[CmdletBinding(SupportsShouldProcess)]'],
|
||||
['a.ps1', 'FILTER Only-Odd { }', 'FILTER Only-Odd { $_ }'],
|
||||
['a.bat', 'set X=1', 'set X=2']
|
||||
];
|
||||
for (const [file, before, after] of hits) assert.strictEqual(surface(file, before, after), true, `${file}: ${before}`);
|
||||
});
|
||||
|
||||
test('shell: bodies and local variables do not touch the surface', () => {
|
||||
const misses = [
|
||||
['a.sh', ' echo "building"', ' echo "building all"'],
|
||||
['a.sh', ' local dir=$1', ' local dir=$2'],
|
||||
['a.sh', 'count=1', 'count=2'],
|
||||
['a.sh', ' exporter --run', ' exporter --run --fast'],
|
||||
['a.ps1', ' Write-Output $x', ' Write-Output $y'],
|
||||
['a.ps1', ' $functionName = 1', ' $functionName = 2']
|
||||
];
|
||||
for (const [file, before, after] of misses) assert.strictEqual(surface(file, before, after), false, `${file}: ${before}`);
|
||||
});
|
||||
|
||||
test('shell: redirection to files, HTTP clients, jq and CSV touch data', () => {
|
||||
const hits = [
|
||||
['a.sh', 'echo "$line" >> "$OUT"'],
|
||||
['a.sh', 'sort < input.txt'],
|
||||
['a.sh', 'cmd &> run.log'],
|
||||
['a.sh', 'curl -s "$URL"'],
|
||||
['a.sh', 'wget -q "$URL"'],
|
||||
['a.sh', "jq -r '.items[]'"],
|
||||
['a.sh', 'cut -d, -f2 report.csv'],
|
||||
['a.ps1', '$r = Invoke-RestMethod $u'],
|
||||
['a.ps1', 'Get-Content $p | Out-File out.txt'],
|
||||
['a.ps1', 'Import-Csv rows.csv'],
|
||||
['a.ps1', 'Set-Content -Path $p -Value $v'],
|
||||
['a.bat', 'type a.txt > b.txt']
|
||||
];
|
||||
for (const [file, text] of hits) assert.strictEqual(data(file, 'x=1', text), true, `${file}: ${text}`);
|
||||
});
|
||||
|
||||
test('shell: descriptor duplication and null devices do not touch data', () => {
|
||||
const misses = [
|
||||
['a.sh', 'make build 2>&1'],
|
||||
['a.sh', 'command -v git >/dev/null 2>&1'],
|
||||
['a.sh', 'echo "done" >&2'],
|
||||
['a.sh', 'if [ "$a" -gt 1 ]; then echo hi; fi'],
|
||||
['a.ps1', 'git fetch > $null'],
|
||||
['a.ps1', 'Write-Output ($a -gt 1)'],
|
||||
['a.bat', 'del tmp >nul 2>&1']
|
||||
];
|
||||
for (const [file, text] of misses) assert.strictEqual(data(file, 'x=1', text), false, `${file}: ${text}`);
|
||||
});
|
||||
|
||||
test('shell: comment-only and whitespace-only edits are trivial', () => {
|
||||
const cases = [
|
||||
['a.sh', '# build\n', '# Build all targets.\n'],
|
||||
['a.sh', 'make all # old', 'make all # new'],
|
||||
['a.sh', 'make all', 'make all'],
|
||||
['a.bash', 'n=${#arr[@]} # old', 'n=${#arr[@]} # new'],
|
||||
['a.sh', 'echo "$#" ; # old', 'echo "$#" ; # new'],
|
||||
['a.sh', 'echo "${name}" # old', 'echo "${name}" # new'],
|
||||
['a.zsh', 'if true; then\n x\nfi', 'if true; then\n x\nfi'],
|
||||
['a.ps1', '# old\nGet-Item a', '# new\nGet-Item a'],
|
||||
['a.ps1', '<# old\nhelp #>\nGet-Item a', '<# new\nhelp #>\nGet-Item a'],
|
||||
['a.ps1', "Write-Output 'a''b' # old", "Write-Output 'a''b' # new"],
|
||||
['a.bat', 'REM old\necho a', 'REM new\necho a'],
|
||||
['a.cmd', '@rem old\necho a', '@REM new\necho a'],
|
||||
['a.bat', 'echo a\n\necho b', 'echo a\necho b']
|
||||
];
|
||||
for (const [file, before, after] of cases) {
|
||||
assert.strictEqual(trivial(file, before, after), true, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shell: # inside strings, words, heredocs and substitutions is never a comment', () => {
|
||||
const cases = [
|
||||
['a.sh', 'echo "a # b"', 'echo "a # c"'],
|
||||
['a.sh', "echo 'a # b'", "echo 'a # c'"],
|
||||
['a.sh', 'echo a#b', 'echo a#c'],
|
||||
['a.sh', 'x=#old', 'x=#new'],
|
||||
['a.sh', 'cat <<EOF\n# a\nEOF', 'cat <<EOF\n# b\nEOF'],
|
||||
['a.sh', 'cat <<-EOF\n\t# a\n\tEOF', 'cat <<-EOF\n\t# b\n\tEOF'],
|
||||
['a.sh', "cat <<'EOF'\n# a\nEOF", "cat <<'EOF'\n# b\nEOF"],
|
||||
['a.bash', 'grep x <<< "# a"', 'grep x <<< "# b"'],
|
||||
['a.sh', 'echo "$(printf " #a")"', 'echo "$(printf " #b")"'],
|
||||
['a.sh', 'echo "${x:-" #a"}"', 'echo "${x:-" #b"}"'],
|
||||
['a.sh', 'echo `echo #a`', 'echo `echo #b`'],
|
||||
['a.bash', "echo $'it\\'s' # a", "echo $'it\\'s' # b"],
|
||||
['a.sh', "echo 'a\\' ' x '", "echo 'a\\' ' x '"],
|
||||
['a.sh', 'echo a\\ b', 'echo a\\ b'],
|
||||
['a.zsh', 'print -l *(#q.) # a', 'print -l *(#q.) # b'],
|
||||
['a.sh', 'echo "multi\n# a"', 'echo "multi\n# b"'],
|
||||
['a.ps1', 'Write-Output "a # b"', 'Write-Output "a # c"'],
|
||||
['a.ps1', "Write-Output 'a # b'", "Write-Output 'a # c'"],
|
||||
['a.ps1', 'Write-Output a#b', 'Write-Output a#c'],
|
||||
['a.ps1', '$s = @"\n# a\n"@', '$s = @"\n# b\n"@'],
|
||||
['a.ps1', "$s = @'\n# a\n'@", "$s = @'\n# b\n'@"],
|
||||
['a.ps1', 'Write-Output "$(Get-X " #a")"', 'Write-Output "$(Get-X " #b")"'],
|
||||
['a.ps1', 'Write-Output \u201ca # b\u201d', 'Write-Output \u201ca # c\u201d']
|
||||
];
|
||||
for (const [file, before, after] of cases) {
|
||||
assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('shell: directives, continuations and significant whitespace are never trivial', () => {
|
||||
const cases = [
|
||||
['a.sh', '#!/bin/bash\nset -e', '#!/bin/sh\nset -e'],
|
||||
['a.sh', 'make \\\n all', 'make \\\n# note\n all'],
|
||||
['a.sh', 'echo a # old', 'echo a\\ # new'],
|
||||
['a.ps1', '#Requires -Version 5\nGet-Item a', '#Requires -Version 7\nGet-Item a'],
|
||||
['a.ps1', '#requires -RunAsAdministrator', '# requires nothing'],
|
||||
['a.ps1', 'Get-Item `\n a', 'Get-Item `\n# note\n a'],
|
||||
['a.ps1', '<# a <# b #> #>', '<# a <# c #> #>'],
|
||||
['a.ps1', 'Get-Item a <# old #>', 'Get-Item a<# new #>'],
|
||||
['a.bat', 'echo a b', 'echo a b'],
|
||||
['a.bat', ':: old\necho a', ':: new\necho a'],
|
||||
['a.bat', 'REM 100%\necho a', 'REM 50%\necho a'],
|
||||
['a.bat', 'echo a ^\nREM old', 'echo a ^\nREM new'],
|
||||
['a.bat', 'echo a & REM old', 'echo a & REM new'],
|
||||
['a.bat', 'REMARK old', 'REMARK new'],
|
||||
['a.sh', 'echo a\r#b', 'echo a\r#c'],
|
||||
['a.sh', 'echo a\vb', 'echo a b'],
|
||||
['a.ps1', 'Write-Output a\u00a0#b', 'Write-Output a\u00a0#c'],
|
||||
['a.bat', '\vREM old\necho a', '\vREM new\necho a'],
|
||||
['a.bat', '(\n REM old)\n echo a\n)', '(\n REM new)\n echo a\n)'],
|
||||
['a.bat', 'REM a > b', 'REM a > c']
|
||||
];
|
||||
for (const [file, before, after] of cases) {
|
||||
assert.strictEqual(trivial(file, before, after), false, `${file}: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── data ──
|
||||
console.log('\ndata:');
|
||||
|
||||
@@ -299,7 +459,7 @@ test('comment tricks that can hide code are never trivial', () => {
|
||||
test('Write, unknown languages and MultiEdit with any non-trivial entry are not trivial', () => {
|
||||
assert.strictEqual(write('a.js', '// only a comment\n').trivial, false);
|
||||
assert.strictEqual(trivial('a.rb', '# a', '# b'), false);
|
||||
assert.strictEqual(trivial('a.sh', '# a', '# b'), false);
|
||||
assert.strictEqual(trivial('a.fish', '# a', '# b'), false);
|
||||
const multi = edits => profileChange({ filePath: 'a.js', tool: 'Edit', edits });
|
||||
assert.strictEqual(multi([{ old_string: '// a', new_string: '// b' }, { old_string: '// c', new_string: '// d' }]).trivial, true);
|
||||
assert.strictEqual(multi([{ old_string: '// a', new_string: '// b' }, { old_string: 'f(1)', new_string: 'f(2)' }]).trivial, false);
|
||||
@@ -330,10 +490,14 @@ test('64 KiB adversarial inputs profile in linear time', () => {
|
||||
'aA'.repeat(size / 2),
|
||||
'# \\'.repeat(Math.floor(size / 3)),
|
||||
'func ('.repeat(Math.floor(size / 6)),
|
||||
'select from '.repeat(Math.floor(size / 12))
|
||||
'select from '.repeat(Math.floor(size / 12)),
|
||||
'"${'.repeat(Math.floor(size / 3)) + '}',
|
||||
'<# '.repeat(Math.floor(size / 3)),
|
||||
"'' ".repeat(Math.floor(size / 3)),
|
||||
'> '.repeat(size / 2)
|
||||
];
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
for (const file of ['a.js', 'a.py', 'a.go', 'a.rs', 'a.c']) {
|
||||
for (const file of ['a.js', 'a.py', 'a.go', 'a.rs', 'a.c', 'a.sh', 'a.ps1', 'a.bat']) {
|
||||
const start = process.hrtime.bigint();
|
||||
edit(file, input, `${input} `);
|
||||
write(file, input);
|
||||
|
||||
Reference in New Issue
Block a user