mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-15 04:35:29 +02:00
docs(gateguard): move change-profile notes into their own file
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user