perf(gateguard): profile a change only when used and check its window first

This commit is contained in:
Jeffrey Montoya
2026-09-30 12:13:18 -04:00
parent d39fcd8ac3
commit 2ec52af6d0
5 changed files with 35 additions and 5 deletions
+13
View File
@@ -254,12 +254,25 @@ against the file it applies to.
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)`
+11 -2
View File
@@ -1674,6 +1674,11 @@ function countTrivialAllow() {
return saveState({ ...state, trivial_allows: getTrivialAllowCount(state) + 1 });
}
// see docs/gateguard/change-profile.md#when-a-change-is-profiled
function needsProfile(toolName, cls) {
return targetClass().questionsUseProfile(cls) || ((toolName === 'Edit' || toolName === 'MultiEdit') && TRIVIAL_CLASSES.has(cls)) || isMetricsEnabled();
}
function isTrivialChange(cls, restricted, profile) {
return !restricted && TRIVIAL_CLASSES.has(cls) && Boolean(profile) && profile.known === true && profile.trivial === true;
}
@@ -2332,7 +2337,9 @@ function gate(rawInput) {
recordDecision('credit', 'prior-search', { target: filePath, cls, sensitive });
return { additionalContext: creditNote(credit, filePath), exitCode: 0 };
}
const profile = restricted || toolName === 'NotebookEdit' ? null : changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data);
const profile = restricted || toolName === 'NotebookEdit' || !needsProfile(toolName, cls)
? null
: changeProfileFor(toolName, filePath, [toolInput], toolInput.content, data);
if (toolName === 'Edit' && isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
@@ -2406,7 +2413,9 @@ function gate(rawInput) {
notes.push(creditNote(credit, filePath));
continue;
}
const profile = restricted ? null : changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data);
const profile = restricted || !needsProfile('MultiEdit', cls)
? null
: changeProfileFor('Edit', filePath, entriesFor(edits, fileKey, data), undefined, data);
if (isTrivialChange(cls, restricted, profile)) {
if (!countTrivialAllow()) {
return allowWithStateWarning();
+3 -2
View File
@@ -708,12 +708,13 @@ function trivialInFile(pairs, fileText, spec) {
const start = windowStart(text, found.first);
const end = windowEnd(text, found.last + oldString.length);
work += start + 2 * (end - start);
if (end - start > MAX_WINDOW_CHARS || work > MAX_CONTEXT_WORK || !spec.startsFresh(text.slice(0, start), spec)) return false;
if (end - start > MAX_WINDOW_CHARS || work > MAX_CONTEXT_WORK) return false;
const before = text.slice(start, end);
const after = replaceAll
? before.split(oldString).join(newString)
: `${text.slice(start, found.first)}${newString}${text.slice(found.first + oldString.length, end)}`;
if (!isTrivialEdit(before, after, spec)) return false;
// see docs/gateguard/change-profile.md#file-context
if (!isTrivialEdit(before, after, spec) || !spec.startsFresh(text.slice(0, start), spec)) return false;
text = `${text.slice(0, start)}${after}${text.slice(end)}`;
if (text.length > MAX_FILE_CHARS) return false;
}
+6
View File
@@ -193,6 +193,11 @@ function codeQuestionIds(isWrite, profile) {
}
/** Stable ids of the first-touch questions for a class, action and change profile. */
/** True when a class's questions depend on the change profile. */
function questionsUseProfile(cls) {
return !Object.hasOwn(CLASS_QUESTION_IDS, cls);
}
function questionIdsFor(cls, isWrite, profile) {
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls) ? CLASS_QUESTION_IDS[cls](Boolean(isWrite)) : codeQuestionIds(Boolean(isWrite), profile);
return [...ids, 'quote-instruction'];
@@ -407,6 +412,7 @@ function collapseGateDir(filePath, data, cls) {
}
module.exports = {
questionsUseProfile,
WINDOWS_PATH_PATTERN,
COLLAPSIBLE_CLASSES,
CLASS_QUESTIONS,
+2 -1
View File
@@ -7411,7 +7411,8 @@ function runTests() {
['a first shell command loads only the read-only check', lazyBash('npm test'), [], ['gateguard-readonly-shell', 'gateguard-state']],
['a shell command after the routine gate loads only the state helpers', lazyBash('npm run build'), [lazyBash('npm test')], ['gateguard-state']],
['an edit of a checked file loads only the target classification', lazyEdit, [lazyEdit], ['gateguard-state', 'gateguard-target-class']],
['a first touch without a transcript loads no search matching', lazyEdit, [], ['file-tail', 'gateguard-change-profile', 'gateguard-code-lexer', 'gateguard-file-context', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']]
['a first touch without a transcript loads no search matching', lazyEdit, [], ['file-tail', 'gateguard-change-profile', 'gateguard-code-lexer', 'gateguard-file-context', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']],
['a first touch of an instruction file skips the change profile', { ...lazyEdit, tool_input: { ...lazyEdit.tool_input, file_path: '/tmp/gateguard-lazy/CLAUDE.md' } }, [], ['file-tail', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']]
];
for (const [label, payload, setup, expected] of lazyCases) {
if (test(`lazy loading: ${label}`, () => assert.deepStrictEqual(loadedLibs(payload, setup), expected))) passed++;