19 KiB
GateGuard change profile notes
Rationale for how GateGuard reads the change itself: which questions a
denial asks and which edits pass as trivial
(scripts/lib/gateguard-change-profile.js, gateguard-code-lexer.js and
gateguard-file-context.js). Code comments point here as
docs/gateguard/change-profile.md#<anchor>; keep headings stable, since they
are the anchors. The other decisions are in design-notes.md.
Change profile
scripts/lib/gateguard-change-profile.js reads the change the hook already
receives (Edit old_string/new_string, each MultiEdit entry for the target,
Write content) and derives three booleans: touchesPublicSurface,
touchesData and trivial. It is string analysis of those texts and, for
trivial, of the current file text the hook passes in (see
File context): no reads of its own, no RegExp built from
input, and every loop is a single linear pass.
The profile only ever removes questions, so anything uncertain yields the
unknown profile (every question asked, never trivial): an unsupported
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), 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
A Write always touches the public surface (a new or rewritten module's surface is all new). For an Edit, any line on either side that declares or exposes a public name counts, whether it changed or is only context: context around a public declaration means the edit is inside that symbol.
- JS/TS: a line starting with the word
export,publicordeclare, or containing the wordexports(module.exports,exports.x)..d.ts,.d.mtsand.d.ctsfiles are all surface. - Python:
def,async deforclasswhose name does not start with_(dunders such as__init__are public), any__all__, and a column-0 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'sold_stringcan begin after the indentation), so there only an all-uppercase name counts, the constant convention that function locals rarely use.__init__.pyis all surface. - Go:
func(after an optional receiver),type,varorconstwith a capitalised name, apackageline, and any line that starts with a capital letter (exported fields, interface methods, grouped declarations). - Rust: lines starting with
pub(anypub(...)),impl,trait,extern, or an ABI or trait attribute (#[macro_export,#[derive,#[repr,#[no_mangle,#[export_name, and their#[unsafe(...)]forms). - POSIX shells: a function definition (
name() {,function name), anexport, ordeclare -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,[CmdletBindingor$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 :labelmake 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 the call sites that rely on the behaviour.
A member line can be public surface even though its container's declaration
is not in the snippet: a property of an exported interface, a variant of a
public enum, a name in an export list. So, when the hook passes the file text,
the nearest line above the first occurrence that starts at column 0 (skipping
blank lines, comments and decorators; a closing }, ] or ) there means
the edit is at top level) is read as the enclosing opener:
- JS/TS:
module.exports/exportsassigned an object, array or call, andexport(orexport default) of an interface, enum, type, namespace, module,declare,{ ... }list, orconst/let/varthat is not a function or arrow: every line inside is surface. An exported class: lines at the class's member indentation (the first non-blank line after the opener) are surface; method bodies are not. An exportedfunctionorasync functionis a body, as before. - Python:
__all__makes every line surface; a publicclassmakes an assignment or annotation of a public name at member indentation (class attributes, dataclass fields) surface. - Rust: a
pubenum, struct, union, trait orusemakes lines at member indentation surface.
The upward scan stops after 4 MiB in total and then counts as surface. The opener is found by indentation, not by parsing, so it can over-match (a method body inside an exported object literal counts), which only keeps the importer question.
Data handling
touchesData looks at every side (both sides of an Edit) as words: identifiers
are split on non-alphanumerics, _ and camelCase boundaries and lowercased,
so validate and updated never match date. It is true for a data word
(formats such as json, csv, yaml, parquet; serialisation; schema;
SQL and database words; date/time words such as date, datetime,
timestamp, strftime, utc, chrono, zoneinfo; encodings such as
base64, encoding, msgpack; browser storage and cookies; data-store
clients such as redis, prisma, sqlalchemy; file I/O such as fs,
fopen, pathlib), for open(, for adjacent pairs such as read file,
time now, time parse, system time,
write text, read to, and for SQL keyword pairs anywhere in the text
(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.
External-contract question
GATEGUARD_EXTERNAL_CONTRACT_QUESTION (1, true, on, enabled,
enable or yes) adds external-contract right after every data-schema
in the table above: "Name what outside this repository decides the format,
units, timezone or protocol semantics here (the consumer, the producer, or a
stated convention), or state that the choice is unconstrained". The condensed
hint and the decision metrics name it too. It is off by default because it
has no evidence behind it: it was designed after inspecting the
hidden-intent corpus, and in the rerun that added it the gated arm did not
improve (see
question-effectiveness.md).
questionIdsFor(class, isWrite, profile, { externalContract: true }) returns
the ids with it.
Trivial edits
An Edit is trivial when, for every entry, old and new have the same code once
comments are removed, blank and comment-only lines are dropped, and runs of
whitespace between tokens are folded to one space. Line structure, whitespace
inside strings, and the presence of whitespace between tokens stay code
(a+b to a + b is not trivial). For Python, leading whitespace of each code
line is code; comment-only lines carry no indentation.
Comments are // and non-nesting /* */ in the C family and # in Python;
C preprocessor lines are code. Strings are single-line "..." and '...'
(Rust: "..." only, with 'x' char literals told apart from lifetimes).
Anything the lexer cannot read with confidence makes the entry non-trivial:
- any multi-line string form: JS/Go backticks, Python/Java/Kotlin/C# triple
quotes, Rust raw strings (
r",r#"), C++ raw strings (R"), C# verbatim and interpolated strings (@",$"), Kotlin strings containing$, Python f-strings, and any string that runs to the end of a line; - in JS/TS, any
/outside a comment (division and regex literals cannot be told apart), JSX-like<x,</,<>,<!, and-->; - a line comment ending in
\(it continues onto the next line in C and Make), the trigraph??/, a/*inside a block comment, and an unterminated block comment or string; - U+2028, U+2029 or U+0085 anywhere in the text: JavaScript ends a line at
the first two and C# at all three, so code after one inside a
//comment runs, while the lexer splits lines only at\nand\r; - a code line ending in a line continuation
\(trailing blanks included): a C macro, a Python explicit continuation or a string continued onto the next line joins that line, so adding or removing a comment-only line after it changes code.
Directive comments
Some comments are read by the language, the build or a tool, so their text is code. A directive comment is kept in the compared code verbatim: changing, adding or removing one makes the entry non-trivial, while an unchanged directive next to an edited ordinary comment does not.
- A comment whose text starts with
!(shebangs, Rust inner docs,/*!),/(///doc comments and TypeScript triple-slash references),go:,exportorexternfollowed by a blank (cgo), orlinefollowed by a blank (Go line directives), with no blank after the comment marker. - A comment whose first word, after blanks,
*and/, starts with@,#,<,+,!,type:orrequires, or isglobal,globalsorexportedfollowed by a blank (JSDoc and TypeScript pragmas,//# sourceMappingURL,// +build, Python type comments, ESLint globals, PowerShell#Requires). - A comment containing a tool or encoding marker such as
lint,ts-,noqa,nosec,pragma,coding:,fmt:,istanbul,prettier-,webpack,__PURE__,fallthrough,shellcheck,suppress,sonar,gitleaks,allowlist,vim:,-*-,DO NOT EDIT,#compdefor Go's example// Output:, or a JSDoc type (@together with{). The list is inDIRECTIVE_WORDS; matching is case-insensitive and substring-based, so it over-matches prose that happens to contain a marker, which only keeps an edit gated. - Every Rust doc comment (
///,//!,/**,/*!): doc tests compile and run.
Suppression markers (nosec, NOSONAR, gitleaks:allow,
pragma: allowlist secret, eslint-disable) matter most: removing a finding
from a security scanner is not a comment edit.
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 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.#Requiresand#!are directive comments, as in every language. 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
REMlines (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. AREMline holding%,^,&,|,<,>,(or), and any line ending in^, are not trivial. Code lines are compared exactly:echokeeps 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
before sibling collapse and the denial cap. It is not marked checked and does
not touch the denial count or ordinal, so the next change that alters code is
gated as a first touch; trivial_allows counts the passes (merged by maximum
like the other counters). The trivial pass never marks the target checked and
never applies to sensitive, hard-linked, instruction or config targets or to
a NotebookEdit, so the next
non-trivial change still meets the full gate.
File context
A snippet alone cannot show whether a comment-looking line is a comment: it may sit inside a template literal, a docstring, a raw string or a heredoc that opens above it, follow a line that continues onto it, or lose its line break so the next line joins it. So an Edit is trivial only when checked against the file it applies to.
- The hook reads the current target (never a sensitive or hard-linked one) itself: the path
is resolved like every other target, opened read-only and non-blocking,
and used only if
fstatsays it is a regular file of at most 1 MiB; CRLF is folded to LF, as the Edit tool does. A missing, unreadable, larger or special file (directory, FIFO, device) gives no file text and the edit is not trivial. The text is never logged, stored or put in a message. - Each entry is applied in order, as the tool applies it, to the evolving
text.
old_stringmust be non-empty and occur exactly once, or at least once withreplace_all; otherwise the tool would fail and the entry is not trivial. Over 1 MiB of text after an edit, more than 8 MiB of scanning for one call, or an edit window over 128 KiB is not trivial. Anew_stringholding aString.prototype.replacepattern ($$,$&,$`,$',$<,$and a digit) is not trivial, in case the tool expands it. - The edit window runs from the start of the line before the first
occurrence to the end of the line holding the last occurrence's end
(through the next line when
old_stringends in a line break), so a line that loses or gains a break is compared joined. The window before and after the replacement is compared with the lexer rules above. - The text before the window must end in plain code: not inside a comment,
string, template literal (including
${...}), text block, raw string, heredoc or here-string, and not after a line continuation (\in C-family, Python and POSIX shells, a backtick in PowerShell,^in batch). A per-language scanner decides; anything it cannot follow makes the edit not trivial: a JS/after}with another/or quote later on the line (regex or division), JSX in.js/.jsx/.tsx(.ts,.mtsand.ctsallow<generics),<!--and-->, a Python f-string whose replacement field holds its own quote, a comment or a line break, C# raw strings and interpolation holes with quotes or braces, Kotlin${inside strings, shell command substitution holdingcase, a heredoc or a comment, a heredoc whose terminator never comes, and nesting deeper than eight levels. - Go files that import
"C"are never trivial: the comment before the import is C code. - The window comparison runs before the prefix scan. Both must pass, so the order changes no result, but most first-touch edits change code and fail the window comparison, which spares them a scan of everything above the edit.
The scanner was checked against real tokenizers over this repository: every
line start it calls plain code in 801 JS files (espree) and 146 Python files
(tokenize) is outside strings, templates, regex literals and comments and
not a backslash continuation.
When a change is profiled
The profile is computed only when its result is used: for classes whose questions depend on it (code), for Edit and MultiEdit calls on the classes that can pass as trivial (code, test, prose), and for every call when decision metrics are on, so the metrics lines keep their profile fields. Instruction and config targets, and new test or prose files, skip it and the lexer modules it needs.
Questions from the change profile
Every question has a stable id, and questionIdsFor(class, isWrite, profile)
returns the ids in the order asked, so later measurement can record which
questions were asked without storing text.
| Target | Profile | Question ids |
|---|---|---|
| code Edit/MultiEdit | unknown | importers, public-api, data-schema, quote-instruction |
| code Edit/MultiEdit | public surface | importers, public-api, [data-schema], quote-instruction |
| code Edit/MultiEdit | no public surface | local-callers, [data-schema], quote-instruction |
| code Write | unknown | callers, no-duplicate, data-schema, quote-instruction |
| code Write | known | callers, no-duplicate, [data-schema], quote-instruction |
| instruction | any | loader, behaviour-change, no-duplicate-instruction, quote-instruction |
| test | any | under-test, existing-tests, quote-instruction |
| prose Write | any | supersedes, linked-from, why-new-file, quote-instruction |
| prose Edit | any | references, corrects-or-adds, quote-instruction |
| config | any | config-reader, config-effect, no-plaintext-secrets, quote-instruction |
[data-schema] is asked only when touchesData. With an unknown profile the
code text is byte-identical to the fixed four questions, unless the opt-in
external-contract question is on. Sensitive and
hard-linked targets and NotebookEdit calls are never profiled, so they always
get the full code questions. Instruction and
config questions carry no change-dependent item, and the test and prose items
do not depend on what the change touches, so the profile leaves those classes
alone. For a MultiEdit, the denied file's profile covers every entry for that
file (same canonical key); entries for other files do not count.
A condensed code denial names the same ids as a full one, one short phrase per
id (condensedQuestionPhrase), so the data and duplicate checks follow the
profile there too. An Edit with an unknown profile keeps the original hint
byte for byte ("importers/callers, affected API, data schemas if any") when
the external-contract question is off. Opting in adds its external-contract
phrase to that hint. A Write with an unknown profile, sensitive Writes included,
names callers, no-duplicate and data-schema like its full denial.