From 21ea5f96a8a34987c42e3b4b11d2acda58e8d3de Mon Sep 17 00:00:00 2001 From: Jeffrey Montoya Date: Tue, 29 Sep 2026 18:04:00 -0400 Subject: [PATCH] feat(gateguard): profile shell scripts and Python module constants --- docs/gateguard/design-notes.md | 61 +++- manifests/context-packs/skill-triggers@1.json | 2 +- scripts/lib/gateguard-change-profile.js | 322 +++++++++++++++++- skills/gateguard/SKILL.md | 27 +- .../08-comment-only-edits.json | 6 +- tests/hooks/gateguard-fact-force.test.js | 67 +++- tests/lib/gateguard-change-profile.test.js | 174 +++++++++- 7 files changed, 618 insertions(+), 41 deletions(-) diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index a84c7f151..62f132709 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -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 diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 1376e77aa..97a04ee88 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -1 +1 @@ -{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"22a0db48cf2f099357d4ab90d78dcd7a882fa65e0d961d503fc641b7d36d14a5","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} +{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"f307355aabdc54aa83309b70c606373aeaec44d95cb83ad82dcc03396947bb45","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} diff --git a/scripts/lib/gateguard-change-profile.js b/scripts/lib/gateguard-change-profile.js index 6396d13ae..cb680bfca 100644 --- a/scripts/lib/gateguard-change-profile.js +++ b/scripts/lib/gateguard-change-profile.js @@ -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 }); diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 2150c283f..3936c59f6 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -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 diff --git a/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json b/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json index b3b55f338..e670a0ee8 100644 --- a/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json +++ b/tests/fixtures/gateguard-scenarios/08-comment-only-edits.json @@ -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" ] diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index a1ce466a5..b6b514022 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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 < { '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 < ${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);