Files
ECC/scripts/lib/gateguard-change-profile.js
T

458 lines
19 KiB
JavaScript

'use strict';
const { isSpace, isIdentChar, isLetter, shCodeLines, psCodeLines, batchCodeLines, normalizeShellLine } = require('./gateguard-code-lexer');
const { MAX_FILE_CHARS, lineEndAt, lineStartOf, cFamilyStartsFresh, pythonStartsFresh, shStartsFresh, psStartsFresh, batchStartsFresh, trivialInFile } = require('./gateguard-file-context');
// see docs/gateguard/change-profile.md#change-profile
const MAX_SIDE_BYTES = 64 * 1024;
const MAX_TOTAL_BYTES = 256 * 1024;
const MAX_EDITS = 64;
const UNKNOWN_PROFILE = Object.freeze({ known: false, language: null, touchesPublicSurface: true, touchesData: true, trivial: false });
const LANGUAGE_BY_EXT = new Map([
['.js', 'js'], ['.mjs', 'js'], ['.cjs', 'js'], ['.jsx', 'js'], ['.ts', 'js'], ['.tsx', 'js'], ['.mts', 'js'], ['.cts', 'js'],
['.py', 'python'], ['.pyi', 'python'],
['.go', 'go'],
['.rs', 'rust'],
['.java', 'java'],
['.kt', 'kotlin'], ['.kts', 'kotlin'],
['.cs', 'csharp'],
['.c', 'c'], ['.h', 'c'],
['.cc', 'cpp'], ['.cpp', 'cpp'], ['.cxx', 'cpp'], ['.hpp', 'cpp'], ['.hh', 'cpp'], ['.hxx', 'cpp'],
['.sh', 'shell'], ['.bash', 'shell'], ['.zsh', 'shell'],
['.ps1', 'powershell'], ['.psm1', 'powershell'],
['.bat', 'batch'], ['.cmd', 'batch']
]);
// --- Lexing ---
// see docs/gateguard/change-profile.md#trivial-edits
const C_FAMILY = { lineComment: '//', blockComment: true, quotes: '"\'', indentSensitive: false };
const C_CONTEXT = { ...C_FAMILY, startsFresh: cFamilyStartsFresh };
const LEX_SPECS = {
js: { ...C_CONTEXT, flavor: 'js', forbiddenCode: '`/', jsx: true },
go: { ...C_CONTEXT, flavor: 'go', forbiddenCode: '`' },
rust: { ...C_CONTEXT, flavor: 'rust', quotes: '"', rustChars: true, rawPrefixes: 'r#', rustDocs: true },
java: { ...C_CONTEXT, flavor: 'java', tripleQuotes: true },
kotlin: { ...C_CONTEXT, flavor: 'kotlin', tripleQuotes: true, noDollarInStrings: true },
csharp: { ...C_CONTEXT, flavor: 'csharp', tripleQuotes: true, rawPrefixes: '@$' },
c: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
cpp: { ...C_CONTEXT, flavor: 'c', rawPrefixes: 'R' },
python: { lineComment: '#', blockComment: false, quotes: '"\'', indentSensitive: true, tripleQuotes: true, fStrings: true, startsFresh: pythonStartsFresh },
shell: { lexer: shCodeLines, normalize: line => normalizeShellLine(line, '\\'), startsFresh: shStartsFresh },
powershell: { lexer: psCodeLines, normalize: line => normalizeShellLine(line, '`'), startsFresh: psStartsFresh },
batch: { lexer: batchCodeLines, normalize: line => line, startsFresh: batchStartsFresh }
};
const TS_EXTS = new Set(['.ts', '.mts', '.cts']);
const TS_SPEC = { ...LEX_SPECS.js, jsx: false };
function lexSpecFor(filePath, language) {
if (language !== 'js') return LEX_SPECS[language];
const base = baseName(filePath);
return TS_EXTS.has(base.slice(base.lastIndexOf('.'))) ? TS_SPEC : LEX_SPECS.js;
}
// --- Public surface ---
function leadingWord(text, word) {
return text.startsWith(word) && !isIdentChar(text[word.length]);
}
function containsWord(text, word) {
for (let at = text.indexOf(word); at !== -1; at = text.indexOf(word, at + 1)) {
if (!isIdentChar(text[at - 1]) && !isIdentChar(text[at + word.length])) return true;
}
return false;
}
function identifierAt(text, start) {
let end = start;
while (end < text.length && isIdentChar(text[end])) end++;
return text.slice(start, end);
}
function skipSpaces(text, at) {
while (at < text.length && isSpace(text[at])) at++;
return at;
}
function isUpper(ch) {
return ch !== undefined && ch >= 'A' && ch <= 'Z';
}
function jsLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'export') || containsWord(t, 'exports') || leadingWord(t, 'public') || leadingWord(t, 'declare');
}
function pythonNameIsPublic(name) {
if (!name) return false;
return !name.startsWith('_') || (name.length > 4 && name.startsWith('__') && name.endsWith('__'));
}
function pythonLineIsSurface(line, index) {
const start = skipSpaces(line, 0);
const t = line.slice(start);
if (t.includes('__all__')) return true;
for (const keyword of ['def', 'class']) {
if (leadingWord(t, keyword)) return pythonNameIsPublic(identifierAt(t, skipSpaces(t, keyword.length)));
}
if (leadingWord(t, 'async')) {
const rest = t.slice(skipSpaces(t, 5));
if (leadingWord(rest, 'def')) return pythonNameIsPublic(identifierAt(rest, skipSpaces(rest, 3)));
}
if (start !== 0) return false;
const name = identifierAt(t, 0);
const rest = skipSpaces(t, name.length);
const after = t[rest];
const assigned = (after === '=' && t[rest + 1] !== '=') || (after === ':' && skipSpaces(t, rest + 1) < t.length);
if (!name || !isLetter(name[0]) || !assigned) return false;
return index > 0 ? pythonNameIsPublic(name) : name === name.toUpperCase();
}
function goLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (isUpper(t[0])) return true;
if (leadingWord(t, 'package')) return true;
for (const keyword of ['type', 'var', 'const']) {
if (leadingWord(t, keyword)) return isUpper(t[skipSpaces(t, keyword.length)]);
}
if (!leadingWord(t, 'func')) return false;
let at = skipSpaces(t, 4);
if (t[at] === '(') {
const close = t.indexOf(')', at);
if (close === -1) return true;
at = skipSpaces(t, close + 1);
}
return isUpper(t[at]);
}
const RUST_SURFACE_ATTRIBUTES = ['#[macro_export', '#[derive', '#[repr', '#[no_mangle', '#[unsafe(no_mangle', '#[export_name', '#[unsafe(export_name'];
function rustLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
return leadingWord(t, 'pub') || leadingWord(t, 'impl') || leadingWord(t, 'trait') || leadingWord(t, 'extern') ||
RUST_SURFACE_ATTRIBUTES.some(attribute => t.startsWith(attribute));
}
function shellFunctionName(t) {
let end = 0;
while (end < t.length && (isIdentChar(t[end]) || t[end] === '-' || t[end] === ':' || t[end] === '.')) end++;
if (end === 0) return false;
const open = skipSpaces(t, end);
return t[open] === '(' && t[skipSpaces(t, open + 1)] === ')';
}
function shellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0));
if (leadingWord(t, 'export') || leadingWord(t, 'function')) return true;
if (leadingWord(t, 'declare') || leadingWord(t, 'typeset')) {
const flags = t.slice(skipSpaces(t, 7));
return flags[0] === '-' && identifierAt(flags, 1).includes('x');
}
return shellFunctionName(t);
}
const POWERSHELL_SURFACE_WORDS = ['function', 'filter', 'workflow', 'class', 'enum', 'param', 'export-modulemember', '[cmdletbinding', '$global:'];
function powershellLineIsSurface(line) {
const t = line.slice(skipSpaces(line, 0)).toLowerCase();
return POWERSHELL_SURFACE_WORDS.some(word => t.startsWith(word) && (word.endsWith(':') || !isIdentChar(t[word.length])));
}
const SURFACE_BY_LANGUAGE = {
js: jsLineIsSurface,
python: pythonLineIsSurface,
go: goLineIsSurface,
rust: rustLineIsSurface,
shell: shellLineIsSurface,
powershell: powershellLineIsSurface
};
function baseName(filePath) {
return String(filePath).replace(/\\/g, '/').split('/').pop().toLowerCase();
}
function wholeFileIsSurface(filePath, language) {
const base = baseName(filePath);
return (language === 'js' && (base.endsWith('.d.ts') || base.endsWith('.d.mts') || base.endsWith('.d.cts'))) ||
(language === 'python' && base === '__init__.py');
}
function textTouchesSurface(text, lineIsSurface) {
let index = 0;
let start = 0;
for (let i = 0; i <= text.length; i++) {
if (i === text.length || text[i] === '\n' || text[i] === '\r') {
if (lineIsSurface(text.slice(start, i), index)) return true;
index++;
start = i + 1;
}
}
return false;
}
// --- Container members ---
// see docs/gateguard/change-profile.md#public-surface
const MAX_OPENER_SCAN = 4 * 1024 * 1024;
const JS_BODY_KINDS = ['function', 'async'];
const JS_MEMBER_KINDS = ['class', 'abstract'];
const JS_CONTAINER_KINDS = ['interface', 'enum', 'type', 'namespace', 'module', 'const', 'let', 'var', 'declare', '{'];
const RUST_CONTAINER_KINDS = ['enum', 'struct', 'union', 'trait', 'use'];
function indentOf(line) {
return line.slice(0, skipSpaces(line, 0));
}
function lineAt(text, start) {
const end = lineEndAt(text, start);
return { text: text.slice(start, end), next: end + (text[end] === '\r' && text[end + 1] === '\n' ? 2 : 1) };
}
function isCommentLine(t) {
return t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('#') || t.startsWith('@');
}
function enclosingOpener(text, at, budget) {
let end = lineStartOf(text, at);
while (end > 0) {
let last = end - 1;
if (text[last] === '\n' && text[last - 1] === '\r') last--;
const start = lineStartOf(text, last);
budget.left -= end - start;
if (budget.left < 0) return null;
const line = text.slice(start, last);
end = start;
if (!line.trim() || isSpace(line[0])) continue;
if ('}])'.includes(line[0])) return null;
if (isCommentLine(line)) continue;
return { line, start };
}
return null;
}
function jsContainerKind(opener) {
const t = opener.trimEnd();
const bodyLike = t.includes('=>') || containsWord(t, 'function');
if (containsWord(t, 'exports')) return !bodyLike && /[{[(]$/.test(t) ? 'all' : null;
if (!leadingWord(t, 'export')) return null;
let rest = t.slice(skipSpaces(t, 6));
if (leadingWord(rest, 'default')) rest = rest.slice(skipSpaces(rest, 7));
if (JS_BODY_KINDS.some(word => leadingWord(rest, word))) return null;
if (JS_MEMBER_KINDS.some(word => leadingWord(rest, word))) return 'members';
if (rest.startsWith('{')) return 'all';
if (!JS_CONTAINER_KINDS.some(word => leadingWord(rest, word))) return null;
return bodyLike ? null : 'all';
}
function containerKind(opener, language) {
if (language === 'js') return jsContainerKind(opener);
if (language === 'python') {
if (opener.includes('__all__')) return 'all';
return leadingWord(opener, 'class') && pythonNameIsPublic(identifierAt(opener, skipSpaces(opener, 5))) ? 'fields' : null;
}
if (language === 'rust' && leadingWord(opener, 'pub')) {
const words = opener.split(/[^A-Za-z0-9_]+/);
return RUST_CONTAINER_KINDS.some(word => words.includes(word)) ? 'members' : null;
}
return null;
}
function memberIndent(text, opener) {
let at = lineAt(text, opener.start).next;
while (at < text.length) {
const line = lineAt(text, at);
if (line.text.trim()) return indentOf(line.text);
at = line.next;
}
return null;
}
function isMemberLine(line, indent, kind) {
if (!indent || indentOf(line) !== indent) return false;
const t = line.slice(indent.length);
if (!t || '}])'.includes(t[0]) || isCommentLine(t)) return false;
if (kind !== 'fields') return true;
const name = identifierAt(t, 0);
const after = t[skipSpaces(t, name.length)];
return pythonNameIsPublic(name) && isLetter(name[0]) && (after === ':' || (after === '=' && t[skipSpaces(t, name.length) + 1] !== '='));
}
function containerTouchesSurface(pairs, fileText, language) {
if (typeof fileText !== 'string' || fileText.length > MAX_FILE_CHARS) return false;
const budget = { left: MAX_OPENER_SCAN };
for (const [oldString, newString] of pairs) {
const at = oldString ? fileText.indexOf(oldString) : -1;
if (at === -1) continue;
const opener = enclosingOpener(fileText, at, budget);
if (budget.left < 0) return true;
const kind = opener ? containerKind(opener.line, language) : null;
if (!kind) continue;
if (kind === 'all') return true;
const indent = memberIndent(fileText, opener);
const start = lineStartOf(fileText, at);
const end = lineEndAt(fileText, at + oldString.length);
const before = fileText.slice(start, end);
const after = `${fileText.slice(start, at)}${newString}${fileText.slice(at + oldString.length, end)}`;
if (`${before}\n${after}`.split(/\r\n|\r|\n/).some(line => isMemberLine(line, indent, kind))) return true;
}
return false;
}
// --- Data handling ---
const DATA_WORDS = new Set([
'json', 'jsonl', 'ndjson', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'toml', 'ini', 'parquet', 'avro', 'arrow', 'pickle', 'protobuf',
'serde', 'serialize', 'serializer', 'serialise', 'deserialize', 'deserializer', 'deserialise', 'marshal', 'unmarshal',
'schema', 'schemas', 'sql', 'sqlite', 'sqlite3', 'postgres', 'mysql', 'mongo', 'mongodb', 'database', 'db', 'cursor',
'migration', 'migrations', 'orm', 'dataframe', 'pandas',
'date', 'dates', 'datetime', 'timestamp', 'timestamps', 'strftime', 'strptime', 'isoformat', 'iso', 'iso8601', 'timezone',
'tz', 'utc', 'epoch', 'dayjs', 'moment', 'luxon', 'instant',
'fs', 'fopen', 'fread', 'fwrite', 'fgets', 'ioutil', 'bufio', 'pathlib', 'shutil', 'readfile', 'writefile', 'fstream',
'ifstream', 'ofstream',
'chrono', 'zoneinfo', 'pytz', 'tzinfo', 'temporal', 'base64', 'encoding', 'charset', 'codec', 'msgpack', 'bson', 'cbor',
'xlsx', 'storage', 'cookie', 'cookies', 'redis', 'prisma', 'knex', 'sequelize', 'mongoose', 'typeorm', 'drizzle',
'sqlalchemy', 'jdbc', 'firestore', 'dynamodb'
]);
const DATA_PAIRS = [
['read', 'file'], ['write', 'file'], ['append', 'file'], ['open', 'file'], ['read', 'text'], ['write', 'text'],
['read', 'bytes'], ['write', 'bytes'], ['read', 'to'], ['read', 'lines'], ['write', 'lines'], ['read', 'all'],
['file', 'reader'], ['file', 'writer'], ['file', 'stream'], ['read', 'csv'], ['to', 'csv'],
['time', 'now'], ['time', 'parse'], ['time', 'unix'], ['time', 'since'], ['time', 'format'], ['time', 'time'],
['time', 'duration'], ['time', 'zone'], ['time', 'stamp'], ['system', 'time']
];
const SQL_PAIRS = [['select', 'from'], ['insert', 'into'], ['delete', 'from'], ['create', 'table'], ['alter', 'table'], ['drop', 'table'], ['update', 'set']];
function* words(text) {
let word = '';
let prev = '';
for (let i = 0; i <= text.length; i++) {
const ch = text[i];
const alnum = ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'));
const boundary = alnum && word && (
(isUpper(ch) && !isUpper(prev) && !(prev >= '0' && prev <= '9')) ||
(isUpper(ch) && isUpper(prev) && text[i + 1] >= 'a' && text[i + 1] <= 'z')
);
if (!alnum || boundary) {
if (word) yield { word: word.toLowerCase(), call: !alnum && text[skipSpaces(text, i)] === '(' };
word = alnum ? ch : '';
} else {
word += ch;
}
prev = ch;
}
}
const SHELL_LANGUAGES = new Set(['shell', 'powershell', 'batch']);
const SHELL_DATA_WORDS = new Set(['curl', 'wget', 'jq', 'yq', 'xmllint', 'psql', 'tee', 'iwr', 'irm', 'clixml']);
const SHELL_DATA_PAIRS = [['out', 'file'], ['get', 'content'], ['set', 'content'], ['add', 'content'], ['web', 'request'], ['rest', 'method']];
const NULL_TARGETS = ['/dev/null', '/dev/stdout', '/dev/stderr', '$null', 'nul'];
function redirectTargetIsFile(text, at) {
const start = skipSpaces(text, at);
const ch = text[start];
if (ch === undefined || ch === '\n' || ch === '\r' || ch === '&') return false;
const rest = text.slice(start, start + 12).toLowerCase();
return !NULL_TARGETS.some(target => rest.startsWith(target) && !isIdentChar(rest[target.length]) && rest[target.length] !== '.');
}
function shellRedirectsFile(text) {
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '>') {
const prev = text[i - 1];
if (prev === '-' || prev === '=' || prev === '>') continue;
let at = i + 1;
if (text[at] === '>' || text[at] === '|') at++;
if (text[at] === '=' || text[at] === '&') continue;
if (redirectTargetIsFile(text, at)) return true;
} else if (ch === '<') {
const next = text[i + 1];
if (text[i - 1] === '<' || next === '<' || next === '(' || next === '&' || next === '#' || next === '=') continue;
if (redirectTargetIsFile(text, i + 1)) return true;
}
}
return false;
}
function textTouchesData(text, language) {
const shell = SHELL_LANGUAGES.has(language);
if (shell && shellRedirectsFile(text)) return true;
const seen = new Set();
let previous = '';
for (const { word, call } of words(text)) {
if (DATA_WORDS.has(word)) return true;
if (shell && (SHELL_DATA_WORDS.has(word) || SHELL_DATA_PAIRS.some(([a, b]) => previous === a && word === b))) return true;
if (word === 'open' && call) return true;
if (DATA_PAIRS.some(([a, b]) => previous === a && word === b)) return true;
seen.add(word);
previous = word;
}
return SQL_PAIRS.some(([a, b]) => seen.has(a) && seen.has(b));
}
// --- Profile ---
/** Language of a target by extension, or null when unsupported. */
function languageFor(filePath) {
if (typeof filePath !== 'string' || !filePath) return null;
const base = baseName(filePath);
const dot = base.lastIndexOf('.');
if (dot <= 0) return null;
return LANGUAGE_BY_EXT.get(base.slice(dot)) || null;
}
function changeTexts(input) {
if (input.tool === 'Write') {
return typeof input.content === 'string' ? { sides: [input.content], pairs: [] } : null;
}
if (input.tool !== 'Edit' || !Array.isArray(input.edits) || input.edits.length === 0 || input.edits.length > MAX_EDITS) return null;
const pairs = [];
for (const entry of input.edits) {
if (!entry || typeof entry !== 'object') return null;
const oldString = entry.old_string;
const newString = entry.new_string;
if (typeof oldString !== 'string' || typeof newString !== 'string') return null;
pairs.push([oldString, newString, entry.replace_all === true]);
}
return { sides: pairs.flatMap(([oldString, newString]) => [oldString, newString]), pairs };
}
function withinBounds(sides) {
let total = 0;
for (const side of sides) {
if (side.length > MAX_SIDE_BYTES) return false;
const bytes = Buffer.byteLength(side, 'utf8');
if (bytes > MAX_SIDE_BYTES) return false;
total += bytes;
if (total > MAX_TOTAL_BYTES) return false;
}
return true;
}
/** Profile of one target's change from Edit/MultiEdit entries or Write content, judged against `fileText` when given; unknown on any doubt. */
function profileChange(input) {
try {
if (!input || typeof input !== 'object') return UNKNOWN_PROFILE;
const language = languageFor(input.filePath);
if (!language) return UNKNOWN_PROFILE;
const texts = changeTexts(input);
if (!texts || !withinBounds(texts.sides)) return UNKNOWN_PROFILE;
const lineIsSurface = SURFACE_BY_LANGUAGE[language];
const touchesPublicSurface = input.tool === 'Write' || !lineIsSurface || wholeFileIsSurface(input.filePath, language) ||
texts.sides.some(side => textTouchesSurface(side, lineIsSurface)) || containerTouchesSurface(texts.pairs, input.fileText, language);
const touchesData = texts.sides.some(side => textTouchesData(side, language));
const trivial = input.tool === 'Edit' && trivialInFile(texts.pairs, input.fileText, lexSpecFor(input.filePath, language));
return Object.freeze({ known: true, language, touchesPublicSurface, touchesData, trivial });
} catch (_) {
return UNKNOWN_PROFILE;
}
}
module.exports = { MAX_SIDE_BYTES, MAX_TOTAL_BYTES, MAX_EDITS, UNKNOWN_PROFILE, languageFor, profileChange };