docs(gateguard): move change-profile notes into their own file

This commit is contained in:
Jeffrey Montoya
2026-09-30 00:23:08 -04:00
parent b15c0538f6
commit f19425dbf1
5 changed files with 320 additions and 301 deletions
+296
View File
@@ -0,0 +1,296 @@
# 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.
## 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;
- 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 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.
## 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. 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. The one exception is an Edit with an unknown profile, which
keeps the original hint byte for byte ("importers/callers, affected API, data
schemas if any"); a Write with an unknown profile, sensitive Writes included,
names `callers`, `no-duplicate` and `data-schema` like its full denial.
+21 -298
View File
@@ -3,7 +3,8 @@
Rationale for the decisions in the GateGuard fact-forcing gate
(`scripts/hooks/gateguard-fact-force.js` and `scripts/lib/gateguard-*.js`).
Code comments point here as `docs/gateguard/design-notes.md#<anchor>`; keep
headings stable, since they are the anchors.
headings stable, since they are the anchors. How a change is read (questions
and trivial edits) is in [change-profile.md](change-profile.md).
## Index
@@ -15,11 +16,11 @@ headings stable, since they are the anchors.
[Target resolution](#target-resolution), [Target classes](#target-classes),
[Worktree prefix](#worktree-prefix),
[Windows name normalization](#windows-name-normalization).
- Right question: [Change profile](#change-profile),
[Public surface](#public-surface), [Data handling](#data-handling),
[Shell scripts](#shell-scripts), [File context](#file-context),
[Questions from the change profile](#questions-from-the-change-profile),
[Trivial edits](#trivial-edits), [Directive comments](#directive-comments),
- Right question: [Change profile](change-profile.md#change-profile),
[Public surface](change-profile.md#public-surface), [Data handling](change-profile.md#data-handling),
[Shell scripts](change-profile.md#shell-scripts), [File context](change-profile.md#file-context),
[Questions from the change profile](change-profile.md#questions-from-the-change-profile),
[Trivial edits](change-profile.md#trivial-edits), [Directive comments](change-profile.md#directive-comments),
[Closest search that did not count](#closest-search-that-did-not-count).
- Right time, prior-search credit: [Read is not evidence](#read-is-not-evidence),
[Turn boundaries](#turn-boundaries), [Turn identity](#turn-identity),
@@ -46,7 +47,11 @@ headings stable, since they are the anchors.
- `scripts/hooks/gateguard-fact-force.js`: the hook. Owns the shell parser
(`quoteAwareSegments`, `commandBasename`, `SHELL_SEGMENT_SEPARATORS`) that the
destructive Bash/PowerShell detector uses, the state file I/O, the gate
messages, and `run()`.
messages, and `run()`. It stays one file above the 800-line guideline: the
destructive detector and the shell parser that the search evidence and the
read-only shell check are built from must not drift apart, and the gate
messages share the hook's state and deny helpers. Logic that needs neither
lives in `scripts/lib/`.
- `scripts/lib/gateguard-target-class.js`: target identity and classification
for first-touch Edit/Write/MultiEdit/NotebookEdit targets: canonical path
keys, target classes and their questions, sensitive-target and hard-link
@@ -62,12 +67,19 @@ headings stable, since they are the anchors.
current turn covers a target (prior-search credit). Its shell-dependent part
is built by `createSearchEvidence()` from the hook's shell parser, so the
parser the destructive detector relies on is shared, not copied or moved.
- `scripts/lib/gateguard-search-filters.js`: the include and exclusion
filters of a search (glob, `--include`/`--exclude`, `find` name tests,
PowerShell `-Filter`/`-Include`/`-Exclude`) and whether they admit a target.
Pure; used by the search evidence.
- `scripts/lib/gateguard-state.js`: pure helpers for the session-state fields
(counters, per-class counts, sibling dir gates). Reading and writing the
state file, and the one-write event helpers built on it, stay in the hook.
- `scripts/lib/gateguard-change-profile.js`: pure analysis of the change text
of an Edit/Write/MultiEdit into a bounded change profile (see
[Change profile](#change-profile)).
[Change profile](change-profile.md#change-profile)). It uses
`gateguard-code-lexer.js`, which reduces a snippet to its code lines, and
`gateguard-file-context.js`, which checks that an edit's window of the file
starts outside any string, comment or heredoc.
- `scripts/lib/gateguard-readonly-shell.js`: decides whether a Bash or
PowerShell command is allowlisted read-only introspection (see
[Read-only first shell command](#read-only-first-shell-command)). Built by
@@ -183,7 +195,7 @@ fallback). Cursor `.mdc` rule files are instructions wherever they live. A
basename starting with `.env` is config unless it has a code extension
(`.env.example.ts` is source). Code targets ask the original four questions,
narrowed by the change profile (see
[Questions from the change profile](#questions-from-the-change-profile)); other
[Questions from the change profile](change-profile.md#questions-from-the-change-profile)); other
classes get class-specific questions and a class-specific condensed hint.
## Worktree prefix
@@ -388,295 +400,6 @@ sensitive-target and trivial-edit rules as an Edit of that file. Entries that
name their own path keep it. Non-object entries are skipped; a non-array
`edits` is treated as empty.
## 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.
## 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;
- 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 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.
## 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. 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. The one exception is an Edit with an unknown profile, which
keeps the original hint byte for byte ("importers/callers, affected API, data
schemas if any"); a Write with an unknown profile, sensitive Writes included,
names `callers`, `no-duplicate` and `data-schema` like its full denial.
## Closest search that did not count
When an ordinary (not sensitive or hard-linked) first touch is denied and the
+1 -1
View File
@@ -21,7 +21,7 @@ with the decision the gate should make and why it matters:
| `expect` | `deny` or `allow` (allow covers prior-search credit, sibling collapse, trivial edits, read-only shell commands and plain passes) |
| `mustDeny` | Security-critical: sensitive targets, bypass attempts, mutating or destructive first commands. Any allow is a bypass. |
| `redundant` | A denial here would repeat investigation the session already did (a scoped search that names the file, a same-turn sibling, a comment-only edit, a read-only first command). |
| `relevantQuestions` | For code targets, the question ids the change genuinely warrants (see the question table in [design-notes.md](design-notes.md#questions-from-the-change-profile)). |
| `relevantQuestions` | For code targets, the question ids the change genuinely warrants (see the question table in [design-notes.md](change-profile.md#questions-from-the-change-profile)). |
Metrics per hook:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -129,7 +129,7 @@ and hard-linked targets and NotebookEdit calls always get the full questions. Ot
questions. Each question has a stable id (`importers`, `public-api`,
`local-callers`, `callers`, `no-duplicate`, `data-schema`,
`quote-instruction`, and one per class question), listed in
`docs/gateguard/design-notes.md`.
`docs/gateguard/change-profile.md`.
### Questions by target class