From 2ec52af6d0fee1197147a407bcafd7400a2609e5 Mon Sep 17 00:00:00 2001 From: Jeffrey Montoya Date: Wed, 30 Sep 2026 12:13:18 -0400 Subject: [PATCH] perf(gateguard): profile a change only when used and check its window first --- docs/gateguard/change-profile.md | 13 +++++++++++++ scripts/hooks/gateguard-fact-force.js | 13 +++++++++++-- scripts/lib/gateguard-file-context.js | 5 +++-- scripts/lib/gateguard-target-class.js | 6 ++++++ tests/hooks/gateguard-fact-force.test.js | 3 ++- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/gateguard/change-profile.md b/docs/gateguard/change-profile.md index 45a03cb5a..1bb8d2c1c 100644 --- a/docs/gateguard/change-profile.md +++ b/docs/gateguard/change-profile.md @@ -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)` diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index dcb6cfd13..77c84a9eb 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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(); diff --git a/scripts/lib/gateguard-file-context.js b/scripts/lib/gateguard-file-context.js index 67996fd2a..4c571c16d 100644 --- a/scripts/lib/gateguard-file-context.js +++ b/scripts/lib/gateguard-file-context.js @@ -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; } diff --git a/scripts/lib/gateguard-target-class.js b/scripts/lib/gateguard-target-class.js index c963de76d..218b425e4 100644 --- a/scripts/lib/gateguard-target-class.js +++ b/scripts/lib/gateguard-target-class.js @@ -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, diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index e0b794300..c29542ba3 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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++;