Files
ECC/scripts/lib/gateguard-code-lexer.js
montjeffrey 64d8971a64 fix(gateguard): credit only searches that could have seen the target
- Read file-type filters as include and exclusion globs: rg -t/--type
  and the Grep tool's type include ripgrep 14.1's globs for the type,
  rg -T/--type-not exclude them, fd -e/--extension includes *.<ext>.
  An unknown type name, --type-add or --type-clear is an unreadable
  filter and never credits, like --exclude-from.
- Treat a path pattern as a filter: the Glob tool's pattern must match
  the target below its root; a shell glob operand must match the target
  or a directory above it; a git pathspec matches the same way with its
  * also matching /. One operand that admits the target is enough.
- Extend "a search of the target itself gives nothing" to searches
  confined to the target's own file name: when every include, the Glob
  pattern, or every path pattern ends in that exact name, the stem never
  credits, and the closest-miss note says it was a lookup.
- Tokenize PowerShell searches with backslashes kept as path separators,
  as the read-only shell check already does; the old literal-prefix scope
  hid that src\core\*.ts reached the parser as srccore*.ts.
- Make an edit non-trivial when either text holds U+2028, U+2029 or
  U+0085, which end a JavaScript or C# line comment; the lexer splits
  lines only at \n and \r.

Extracting applyLongFilter, grepDetail and stemCredits leaves every
touched function at or below its complexity on main.
2026-10-10 23:38:47 -04:00

565 lines
17 KiB
JavaScript

'use strict';
// --- Code lines ---
// see docs/gateguard/change-profile.md#trivial-edits
function isSpace(ch) {
return ch === ' ' || ch === '\t' || ch === '\f' || ch === '\v';
}
function isIdentChar(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch === '_' || ch === '$');
}
function isLetter(ch) {
return ch !== undefined && ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'));
}
function rustCharEnd(text, i) {
if (text[i + 1] === '\\') {
if (text[i + 2] === "'") return text[i + 3] === "'" ? i + 3 : -1;
const end = text.indexOf("'", i + 3);
return end !== -1 && end <= i + 12 ? end : -1;
}
const cp = text.codePointAt(i + 1);
if (cp === undefined || text[i + 1] === '\n' || text[i + 1] === '\r' || text[i + 1] === "'") return -1;
const end = i + (cp > 0xffff ? 3 : 2);
return text[end] === "'" ? end : -1;
}
function stringPrefixIsUnsafe(code, spec) {
const last = code[code.length - 1];
if (spec.rawPrefixes && last !== undefined && spec.rawPrefixes.includes(last)) return true;
if (!spec.fStrings || !isLetter(last)) return false;
const prefix = isLetter(code[code.length - 2]) ? code.slice(-2) : last;
return prefix.toLowerCase().includes('f');
}
// --- Directive comments ---
// see docs/gateguard/change-profile.md#directive-comments
const DIRECTIVE_LEADS = ['@', '#', '<', '+', '!', 'type:', 'global ', 'globals ', 'exported ', 'requires'];
const DIRECTIVE_RAW_LEADS = ['!', '/', 'go:', 'export ', 'extern ', 'line '];
const DIRECTIVE_WORDS = [
'lint', 'jshint', 'ts-', 'noqa', 'nosec', 'semgrep', 'sonar', 'noinspection', 'pragma', 'coding:', 'coding=', 'fmt:', 'isort:',
'mypy:', 'pyright:', 'pyre-', 'yapf:', 'ruff:', 'flake8', 'bandit', 'no cover', 'nocover', 'istanbul', 'c8 ', 'v8 ',
'prettier-', 'biome-', 'deno-', 'dprint-', 'rome-', 'webpack', 'vite-', '__pure__', '__no_side_effects__',
'__inline__', '@preserve', '@license', 'sourcemappingurl', 'sourceurl', '+build', 'cgo', 'clang-', 'cppcheck',
'coverity', 'iwyu', 'fallthrough', 'fall through', 'fall-through', 'fallthru', 'fall thru', 'shellcheck',
'psscriptanalyzer', 'suppress', 'jscs', 'checkstyle', 'spotbugs', 'findbugs', 'nopmd', 'codeql', 'lgtm', 'gitleaks',
'trufflehog', 'detect-secrets', 'allowlist', 'vim:', 'vi:', ' ex:', '-*-', 'code generated', 'do not edit',
'rubocop', 'swiftlint', 'resharper', '@ts-', '@type', '@typedef', '@template', '@satisfies', '@import', '@callback',
'@overload', '@enum', '@this', '@implements', '@extends', '@augments', '@jsx', '@flow', '@noflow', '@generated',
'compdef', 'autoload', 'output:'
];
function isDirectiveComment(body, spec) {
if (spec.rustDocs && (body[0] === '/' || body[0] === '!' || body[0] === '*')) return true;
if (DIRECTIVE_RAW_LEADS.some(lead => body.startsWith(lead))) return true;
const lower = body.toLowerCase();
let start = 0;
while (start < lower.length && (isSpace(lower[start]) || lower[start] === '*' || lower[start] === '/')) start++;
const lead = lower.slice(start);
if (DIRECTIVE_LEADS.some(word => lead.startsWith(word))) return true;
if (DIRECTIVE_WORDS.some(word => lower.includes(word))) return true;
return lower.includes('@') && lower.includes('{');
}
function directiveMarker(body, spec) {
return isDirectiveComment(body, spec) ? ` \u0001${Buffer.from(body, 'utf8').toString('hex')}\u0001 ` : '';
}
function endsWithContinuation(code) {
let end = code.length;
while (end > 0 && isSpace(code[end - 1])) end--;
return code[end - 1] === '\\';
}
const CODE_SPECIALS = new WeakMap();
function escapeClass(chars) {
return chars.replace(/[\\\]^-]/g, ch => `\\${ch}`);
}
function codeSpecials(spec) {
let specials = CODE_SPECIALS.get(spec);
if (!specials) {
const chars = `\n\r/${spec.lineComment === '#' ? '#' : ''}${spec.quotes || ''}${spec.forbiddenCode || ''}${spec.jsx !== undefined ? '<-' : ''}${spec.rustChars ? "'" : ''}`;
specials = { pattern: new RegExp(`[${escapeClass(chars)}]`, 'g'), chars: new Set(chars) };
CODE_SPECIALS.set(spec, specials);
}
return specials;
}
function nextSpecial(pattern, text, i) {
pattern.lastIndex = i;
const match = pattern.exec(text);
return match ? match.index : text.length;
}
const LINE_BREAK = /[\n\r]/g;
const BLOCK_SPECIALS = /[\n\r*/]/g;
const STRING_SPECIALS = new Map();
function stringSpecials(quote) {
let pattern = STRING_SPECIALS.get(quote);
if (!pattern) {
pattern = new RegExp(`[\\n\\r\\\\${escapeClass(quote)}]`, 'g');
STRING_SPECIALS.set(quote, pattern);
}
return pattern;
}
function lexCodeLines(text, spec) {
const specials = codeSpecials(spec);
const lines = [];
let code = '';
let state = 'code';
let quote = '';
let stringBody = '';
let commentStart = 0;
let tail = '';
const append = piece => {
code += piece;
tail = (tail + piece).slice(-2);
};
const endComment = end => {
const marker = directiveMarker(text.slice(commentStart, end), spec);
if (marker) append(marker);
};
const endLine = () => {
if (endsWithContinuation(code)) return false;
lines.push(code);
code = '';
tail = '';
return true;
};
if (spec.jsx !== undefined && text.startsWith('#!')) state = 'line';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
if (state === 'string') return null;
if (state === 'line') {
if (text.slice(commentStart, i).trimEnd().endsWith('\\')) return null;
endComment(i);
state = 'code';
}
if (state === 'block') {
lines.push(code);
code = '';
tail = '';
} else if (!endLine()) {
return null;
}
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (state === 'line') {
i = nextSpecial(LINE_BREAK, text, i) - 1;
continue;
}
if (state === 'block') {
if (ch !== '*' && ch !== '/') {
i = nextSpecial(BLOCK_SPECIALS, text, i + 1) - 1;
continue;
}
if (ch === '*' && text[i + 1] === '/') {
endComment(i);
state = 'code';
append(' ');
i++;
} else if (ch === '/' && text[i + 1] === '*') {
return null;
}
continue;
}
if (state === 'string') {
if (ch !== '\\' && ch !== quote) {
const end = nextSpecial(stringSpecials(quote), text, i + 1);
const run = text.slice(i, end);
append(run);
stringBody += run;
i = end - 1;
continue;
}
append(ch);
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
append(next);
i++;
} else if (ch === quote) {
if (spec.noDollarInStrings && stringBody.includes('$')) return null;
state = 'code';
} else {
stringBody += ch;
}
continue;
}
if (!specials.chars.has(ch)) {
const end = nextSpecial(specials.pattern, text, i + 1);
append(text.slice(i, end));
i = end - 1;
continue;
}
if (spec.lineComment === '//' && ch === '/' && text[i + 1] === '/') {
state = 'line';
commentStart = i + 2;
i++;
continue;
}
if (spec.lineComment === '#' && ch === '#') {
state = 'line';
commentStart = i + 1;
continue;
}
if (spec.blockComment && ch === '/' && text[i + 1] === '*') {
state = 'block';
commentStart = i + 2;
i++;
continue;
}
if (spec.forbiddenCode && spec.forbiddenCode.includes(ch)) return null;
if (spec.jsx && ch === '<' && (isLetter(text[i + 1]) || text[i + 1] === '/' || text[i + 1] === '>' || text[i + 1] === '!')) return null;
if (spec.jsx !== undefined && ch === '-' && text[i + 1] === '-' && text[i + 2] === '>') return null;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(text, i);
append(end === -1 ? ch : text.slice(i, end + 1));
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) {
if (spec.tripleQuotes && text[i + 1] === ch && text[i + 2] === ch) return null;
if (stringPrefixIsUnsafe(tail, spec)) return null;
state = 'string';
quote = ch;
stringBody = '';
append(ch);
continue;
}
append(ch);
}
if (state === 'string' || state === 'block') return null;
if (state === 'line') {
if (text.slice(commentStart).trimEnd().endsWith('\\')) return null;
endComment(text.length);
}
return endLine() ? lines : null;
}
function normalizeCodeLine(line, spec) {
let out = '';
let pendingSpace = false;
let quote = '';
let indent = '';
let leading = true;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (quote) {
out += ch;
if (ch === '\\' && i + 1 < line.length) out += line[++i];
else if (ch === quote) quote = '';
continue;
}
if (isSpace(ch)) {
if (leading) indent += ch;
else pendingSpace = true;
continue;
}
leading = false;
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === "'" && spec.rustChars) {
const end = rustCharEnd(line, i);
out += end === -1 ? ch : line.slice(i, end + 1);
if (end !== -1) i = end;
continue;
}
if (spec.quotes.includes(ch)) quote = ch;
out += ch;
}
if (!out) return '';
return spec.indentSensitive ? `${indent}\u0000${out}` : out;
}
function hasSpaceAfterContinuation(line) {
let end = line.length;
while (end > 0 && isSpace(line[end - 1])) end--;
return end < line.length && line[end - 1] === '\\';
}
function codeSignature(text, spec) {
const lines = spec.lexer ? spec.lexer(text) : lexCodeLines(text, spec);
if (lines === null) return null;
const signature = [];
for (const line of lines) {
if (hasSpaceAfterContinuation(line)) return null;
const normalized = spec.normalize ? spec.normalize(line) : normalizeCodeLine(line, spec);
if (normalized === null) return null;
if (normalized) signature.push(normalized);
}
return signature;
}
// JavaScript ends a line at U+2028 and U+2029, and C# also at U+0085; the lexer splits only at \n and \r.
const UNICODE_LINE_TERMINATORS = /[\u0085\u2028\u2029]/;
function isTrivialEdit(oldString, newString, spec) {
if (UNICODE_LINE_TERMINATORS.test(oldString) || UNICODE_LINE_TERMINATORS.test(newString)) return false;
if (spec.lineComment === '//' && (oldString.includes('??/') || newString.includes('??/'))) return false;
const before = codeSignature(oldString, spec);
if (before === null) return false;
const after = codeSignature(newString, spec);
if (after === null || before.length !== after.length) return false;
return before.every((line, index) => line === after[index]);
}
// --- Shell lexing ---
// see docs/gateguard/change-profile.md#shell-scripts
const SHELL_SPEC = Object.freeze({});
function isLineStart(text, i) {
return i === 0 || text[i - 1] === '\n' || text[i - 1] === '\r';
}
function hashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
if (prev === ' ' || prev === '\t' || prev === ';') return true;
return '()|&<>'.includes(prev) ? null : false;
}
function shDoubleQuoteEnd(text, i) {
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '"') return j;
if (ch === '\n' || ch === '\r' || ch === '`') return -1;
if (ch === '\\') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === '$' && text[j + 1] === '{') {
const close = text.indexOf('}', j + 2);
if (close === -1) return -1;
for (let k = j + 2; k < close; k++) {
if ('"\'\n\r'.includes(text[k])) return -1;
}
j = close;
}
}
return -1;
}
function hasLoneCarriageReturn(text) {
for (let at = text.indexOf('\r'); at !== -1; at = text.indexOf('\r', at + 1)) {
if (text[at + 1] !== '\n') return true;
}
return false;
}
function isBlank(ch) {
return ch === ' ' || ch === '\t';
}
function shCodeLines(text) {
if (text.includes('<<') || text.includes('`') || hasLoneCarriageReturn(text)) return null;
const lines = [];
let code = '';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (ch === '\\') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'") {
if (text[i - 1] === '$') return null;
const end = text.indexOf("'", i + 1);
if (end === -1) return null;
const body = text.slice(i, end + 1);
if (body.includes('\n') || body.includes('\r')) return null;
code += body;
i = end;
continue;
}
if (ch === '"') {
const end = shDoubleQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '#') {
const starts = hashStartsComment(text, i);
if (starts === null) return null;
if (starts) {
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
}
code += ch;
}
lines.push(code);
return lines;
}
const PS_SMART_QUOTES = /[\u2018\u2019\u201a\u201b\u201c\u201d\u201e]/;
function psHashStartsComment(text, i) {
if (isLineStart(text, i)) return true;
const prev = text[i - 1];
return prev === ' ' || prev === '\t' || prev === ';';
}
function psQuoteEnd(text, i) {
const quote = text[i];
for (let j = i + 1; j < text.length; j++) {
const ch = text[j];
if (ch === '\n' || ch === '\r') return -1;
if (quote === '"' && ch === '`') {
if (j + 1 >= text.length || text[j + 1] === '\n' || text[j + 1] === '\r') return -1;
j++;
} else if (quote === '"' && ch === '$' && text[j + 1] === '(') {
return -1;
} else if (ch === quote) {
if (text[j + 1] !== quote) return j;
j++;
}
}
return -1;
}
function psCodeLines(text) {
if (text.includes('@"') || text.includes("@'") || PS_SMART_QUOTES.test(text)) return null;
const lines = [];
let code = '';
let block = false;
let blockStart = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '\n' || ch === '\r') {
lines.push(code);
code = '';
if (ch === '\r' && text[i + 1] === '\n') i++;
continue;
}
if (block) {
if (ch === '<' && text[i + 1] === '#') return null;
if (ch === '#' && text[i + 1] === '>') {
block = false;
code += directiveMarker(text.slice(blockStart, i), SHELL_SPEC) || ' ';
i++;
}
continue;
}
if (ch === '`') {
const next = text[i + 1];
if (next === undefined || next === '\n' || next === '\r') return null;
code += ch + next;
i++;
continue;
}
if (ch === "'" || ch === '"') {
const end = psQuoteEnd(text, i);
if (end === -1) return null;
code += text.slice(i, end + 1);
i = end;
continue;
}
if (ch === '<' && text[i + 1] === '#') {
if (!psHashStartsComment(text, i)) return null;
block = true;
blockStart = i + 2;
i++;
continue;
}
if (ch === '#') {
if (!psHashStartsComment(text, i)) return null;
const start = i + 1;
while (i + 1 < text.length && text[i + 1] !== '\n' && text[i + 1] !== '\r') i++;
code += directiveMarker(text.slice(start, i + 1), SHELL_SPEC);
continue;
}
code += ch;
}
if (block) return null;
lines.push(code);
return lines;
}
function batchCodeLines(text) {
const lines = [];
for (const line of text.split(/\r\n|\r|\n/)) {
if (line.endsWith('^')) return null;
let start = 0;
while (isBlank(line[start])) start++;
if (line[start] === '@') start++;
while (isBlank(line[start])) start++;
const word = line.slice(start, start + 3).toLowerCase();
const after = line[start + 3];
if (word === 'rem' && (after === undefined || isBlank(after))) {
if (/[%^&|<>()]/.test(line)) return null;
lines.push(directiveMarker(line.slice(start + 4), SHELL_SPEC));
} else {
lines.push(line.trim() ? line : '');
}
}
return lines;
}
function normalizeShellLine(line, escape) {
let out = '';
let pendingSpace = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (isBlank(ch)) {
pendingSpace = true;
continue;
}
if (pendingSpace && out) out += ' ';
pendingSpace = false;
if (ch === escape && i + 1 < line.length) {
out += ch + line[++i];
} else if (ch === "'" || ch === '"') {
const end = escape === '`' ? psQuoteEnd(line, i) : ch === "'" ? line.indexOf("'", i + 1) : shDoubleQuoteEnd(line, i);
if (end === -1) return null;
out += line.slice(i, end + 1);
i = end;
} else {
out += ch;
}
}
return out;
}
module.exports = {
isSpace,
isIdentChar,
isLetter,
rustCharEnd,
isBlank,
hashStartsComment,
hasLoneCarriageReturn,
PS_SMART_QUOTES,
psHashStartsComment,
isTrivialEdit,
shCodeLines,
psCodeLines,
batchCodeLines,
normalizeShellLine
};