Files
ECC/docs/gateguard/change-profile.md
T

330 lines
19 KiB
Markdown

# 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](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](#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`, `public` or `declare`, or
containing the word `exports` (`module.exports`, `exports.x`). `.d.ts`,
`.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 (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`,
`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`), 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
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`/`exports` assigned an object, array or call, and
`export` (or `export default`) of an interface, enum, type, namespace,
module, `declare`, `{ ... }` list, or `const`/`let`/`var` that 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 exported `function` or
`async function` is a body, as before.
- Python: `__all__` makes every line surface; a public `class` makes an
assignment or annotation of a public name at member indentation (class
attributes, dataclass fields) surface.
- Rust: a `pub` enum, struct, union, trait or `use` makes 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](question-effectiveness.md#adding-an-external-contract-question-did-not-help)).
`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 `\n` and `\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:`,
`export` or `extern` followed by a blank (cgo), or `line` followed by a
blank (Go line directives), with no blank after the comment marker.
- A comment whose first word, after blanks, `*` and `/`, starts with `@`, `#`,
`<`, `+`, `!`, `type:` or `requires`, or is `global`, `globals` or
`exported` followed 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`, `#compdef` or Go's
example `// Output:`, or a JSDoc type
(`@` together with `{`). The list is in `DIRECTIVE_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. `#Requires`
and `#!` are [directive comments](#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 `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
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 `fstat` says 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_string` must be non-empty and occur exactly once, or at least
once with `replace_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. A `new_string`
holding a `String.prototype.replace` pattern (`$$`, `$&`, `` $` ``, `$'`,
`$<`, `$` 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_string` ends 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`, `.mts` and `.cts`
allow `<` 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 holding `case`, 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](#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.