diff --git a/.github/workflows/pr-regression-gates.yml b/.github/workflows/pr-regression-gates.yml index 15753cd32..278d75aa9 100644 --- a/.github/workflows/pr-regression-gates.yml +++ b/.github/workflows/pr-regression-gates.yml @@ -100,6 +100,17 @@ jobs: GATE_BASE_REF: ${{ github.event.pull_request.base.ref && format('origin/{0}', github.event.pull_request.base.ref) || '' }} run: npm run cog:baseline -- --base-ref "${GATE_BASE_REF:-origin/main}" + - name: "Check sanitizing text controls (#3167)" + timeout-minutes: 10 + run: npm run sanitizing:self + - name: "Check sanitizing text regressions (#3167)" + timeout-minutes: 10 + env: + GATE_BASE_REF: ${{ github.event.pull_request.base.ref && format('origin/{0}', github.event.pull_request.base.ref) || '' }} + run: | + npm run sanitizing:line_length -- --base-ref "${GATE_BASE_REF:-origin/main}" + npm run sanitizing:file_size -- --base-ref "${GATE_BASE_REF:-origin/main}" + rust-regression: name: rust-regression # Skip branch-deletion push events while still requiring pull request validation. diff --git a/package.json b/package.json index 55715e447..7cae58b91 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,10 @@ "test:report:self": "node scripts/report-failed-tests.mjs --self-test", "dup": "jscpd --config .jscpd.json --no-gitignore --reporters console", "dup:changed": "node scripts/check-test-duplication.mjs", - "dup:changed:self": "node scripts/check-test-duplication.mjs --self-test" + "dup:changed:self": "node scripts/check-test-duplication.mjs --self-test", + "sanitizing:line_length": "node scripts/sanitizing_line_length.mjs", + "sanitizing:file_size": "node scripts/sanitizing_file_size.mjs", + "sanitizing:self": "node --test scripts/sanitizing_tests.mjs" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/sanitizing_baseline.mjs b/scripts/sanitizing_baseline.mjs new file mode 100644 index 000000000..9a7db7865 --- /dev/null +++ b/scripts/sanitizing_baseline.mjs @@ -0,0 +1,131 @@ +// Immutable Git inputs shared by the sanitizing gates. No import-time I/O. +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export class GateError extends Error { + constructor(message, exit = 3) { super(message); this.exit = exit; } +} +export const codeOrder = (a, b) => a < b ? -1 : a > b ? 1 : 0; +export function decode(bytes, context) { + try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } + catch { throw new GateError(`${context}: invalid UTF8; repair the Git input`); } +} +function resolveGitExecutable({ cwd, env }) { + const windows = process.platform === 'win32'; + const pathKey = windows ? Object.keys(env).sort(codeOrder).find(key => key.toUpperCase() === 'PATH') : 'PATH'; + const searchPath = (pathKey === undefined ? undefined : env[pathKey]) ?? + (windows ? process.env.PATH : '/usr/bin:/bin'); + const unavailable = detail => new GateError(`Git executable unavailable${detail ? `: ${detail}` : ''}; install Git/check PATH`); + // Search only the configured PATH; no implicit Windows cwd-first lookup. + for (let directory of (searchPath ?? '').split(path.delimiter)) { + if (windows && directory.startsWith('"') && directory.endsWith('"')) directory = directory.slice(1, -1); + const candidate = path.resolve(cwd, directory, windows ? 'git.exe' : 'git'); + try { + if (!fs.statSync(candidate).isFile()) continue; + fs.accessSync(candidate, windows ? fs.constants.F_OK : fs.constants.X_OK); + return candidate; + } catch (error) { + if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw unavailable(error.message); + } + } + throw unavailable(); +} +export function gitRunner(args, { cwd, env = process.env }) { + return spawnSync(resolveGitExecutable({ cwd, env }), args, { + cwd, env, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, + maxBuffer: 64 * 1024 * 1024, windowsHide: true, + }); +} +export function parseArgs(argv, env = process.env) { + let baseRef; + for (let i = 0; i < argv.length; i++) { + if (argv[i] !== '--base-ref' || baseRef !== undefined || i + 1 === argv.length) + throw new GateError(`invalid or duplicate argument ${argv[i]}; use --base-ref `, 2); + baseRef = argv[++i]; + if (!baseRef.trim() || baseRef.startsWith('--')) throw new GateError('empty/invalid --base-ref', 2); + } + return { baseRef: baseRef ?? (env.GATE_BASE_REF?.trim() ? env.GATE_BASE_REF : 'origin/main') }; +} +export function parseTree(bytes) { + const text = decode(bytes, 'ls-tree'); + if (text && !text.endsWith('\0')) throw new GateError('ls-tree: truncated NUL records'); + const result = new Map(); + for (const record of text.split('\0').filter(Boolean)) { + const match = /^(\d{6}) (\w+) ([a-f0-9]{40}(?:[a-f0-9]{24})?)\t([\s\S]+)$/.exec(record); + if (!match || result.has(match[4])) throw new GateError('ls-tree: malformed/duplicate record'); + const [, mode, type, oid, name] = match; + result.set(name, { path: name, mode, type, oid }); + } + return result; +} +export function parseChanged(bytes) { + const text = decode(bytes, 'diff name-status'); + if (text && !text.endsWith('\0')) throw new GateError('diff: truncated NUL records'); + const parts = text.split('\0'); parts.pop(); + if (parts.length % 2) throw new GateError('diff: malformed name-status'); + const changed = []; + for (let i = 0; i < parts.length; i += 2) { + if (!/^[ADMT]$/.test(parts[i]) || !parts[i + 1]) throw new GateError('diff: unexpected status/path'); + changed.push({ status: parts[i], path: parts[i + 1] }); + } + return changed.sort((a, b) => codeOrder(a.path, b.path)); +} +export function createSnapshot({ baseRef = 'origin/main', cwd, runner = gitRunner } = {}) { + if (typeof baseRef !== 'string' || !baseRef.trim()) throw new GateError('empty base ref', 2); + if (!cwd) throw new GateError('explicit repository cwd required', 2); + const git = args => { + let r; + try { r = runner(args, { cwd }); } + catch (e) { throw new GateError(`git ${args[0]} spawn: ${e.message}`); } + if (r.error || r.signal || r.status !== 0 || !Buffer.isBuffer(r.stdout) || + r.stdout.length > 64 * 1024 * 1024 || (r.stderr?.length ?? 0) > 64 * 1024 * 1024) + throw new GateError(`git ${args[0]} failed: exit=${r.status} signal=${r.signal ?? '-'} ${r.error?.message ?? ''} ${r.stderr?.toString() ?? ''}; fetch full history/check objects`); + return r.stdout; + }; + const oid = args => { + const value = decode(git(args), 'Git OID').trim(); + if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value)) throw new GateError('Git returned invalid OID'); + return value; + }; + const resolve = ref => oid(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]); + const headOid = resolve('HEAD'), refOid = resolve(baseRef); + const baseOid = oid(['merge-base', refOid, headOid]); + const baseTree = parseTree(git(['ls-tree', '-r', '-z', '--full-tree', baseOid])); + const headTree = parseTree(git(['ls-tree', '-r', '-z', '--full-tree', headOid])); + const changed = parseChanged(git(['diff', '--no-renames', '--name-status', '-z', baseOid, headOid, '--'])); + const blobs = new Map(); + function readBlob(entry) { + if (!entry) return ''; + if (!['100644', '100755'].includes(entry.mode) || entry.type !== 'blob') + throw new GateError(`${entry.path}: unsupported mode/type ${entry.mode}/${entry.type}; use a regular tracked file`); + if (!blobs.has(entry.oid)) blobs.set(entry.oid, decode(git(['cat-file', 'blob', entry.oid]), entry.path)); + return blobs.get(entry.oid); + } + function addedLines(name) { + const a = baseTree.get(name), b = headTree.get(name); + // Verify modes even for deleted/new paths before invoking blob diff. + readBlob(a); readBlob(b); + if (!a) return new Set(readBlob(b).split(/\r?\n/).map((_, i) => i + 1)); + if (!b) return new Set(); + const diff = decode(git(['diff', '--text', '--no-ext-diff', '--no-textconv', '--no-renames', '-U0', a.oid, b.oid, '--']), name); + const lines = new Set(); + for (const m of diff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) { + const start = Number(m[1]), count = m[2] === undefined ? 1 : Number(m[2]); + for (let i = 0; i < count; i++) lines.add(start + i); + } + return lines; + } + return { requestedRef: baseRef, refOid, baseOid, headOid, baseTree, headTree, changed, readBlob, addedLines, + assertStableHead() { if (resolve('HEAD') !== headOid) throw new GateError(`HEAD drift from ${headOid}; retry on a stable checkout`); } }; +} +export function isMain(url) { return !!process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(url); } +export async function gateCli(runGate, argv = process.argv.slice(2)) { + try { + const options = parseArgs(argv); + const snapshot = createSnapshot({ ...options, cwd: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') }); + const result = await runGate(snapshot); + console.log(JSON.stringify(result, null, 2)); return result.exit; + } catch (e) { console.error(`ERROR ${e.message}`); return e.exit ?? 3; } +} diff --git a/scripts/sanitizing_file_size.mjs b/scripts/sanitizing_file_size.mjs new file mode 100644 index 000000000..400e1ccdd --- /dev/null +++ b/scripts/sanitizing_file_size.mjs @@ -0,0 +1,7 @@ +import { gateCli, isMain } from './sanitizing_baseline.mjs'; +import { fileMetrics, textGate } from './sanitizing_line_metrics.mjs'; +export function runGate(snapshot) { + return textGate(snapshot, '02', text => fileMetrics(text).lines, + (base, head) => head > 1000 && head > base); +} +if (isMain(import.meta.url)) process.exitCode = await gateCli(runGate); diff --git a/scripts/sanitizing_line_length.mjs b/scripts/sanitizing_line_length.mjs new file mode 100644 index 000000000..63dd2aa71 --- /dev/null +++ b/scripts/sanitizing_line_length.mjs @@ -0,0 +1,7 @@ +import { gateCli, isMain } from './sanitizing_baseline.mjs'; +import { lineLengths, textGate } from './sanitizing_line_metrics.mjs'; +export function runGate(snapshot) { + return textGate(snapshot, '01', text => lineLengths(text).filter(n => n > 100).length, + (base, head) => head > base); +} +if (isMain(import.meta.url)) process.exitCode = await gateCli(runGate); diff --git a/scripts/sanitizing_line_metrics.mjs b/scripts/sanitizing_line_metrics.mjs new file mode 100644 index 000000000..38fba3a89 --- /dev/null +++ b/scripts/sanitizing_line_metrics.mjs @@ -0,0 +1,42 @@ +// Pure functions ported from project-shared/Guidelines/_sanitizing/line-metrics.mjs:10–24. +// Count Unicode code points, exclude CRLF terminators and one final empty segment. +export function lineLengths(text) { + const lines = text.split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + return lines.map(line => [...line].length); +} +export function fileMetrics(text) { + const lengths = lineLengths(text); + const nonBlank = lengths.filter(n => n > 0); + const avg = values => values.length ? values.reduce((sum, n) => sum + n, 0) / values.length : 0; + return { lines: lengths.length, max: lengths.length ? Math.max(...lengths) : 0, + mean: avg(lengths), meanNonBlank: avg(nonBlank) }; +} +// Shared traversal keeps both text gates identical about Git inputs and error precedence. +export function textGate(snapshot, gate, measure, violates) { + const diagnostics = [], metrics = []; + let fail = false; + try { + for (const { path } of snapshot.changed.filter(entry => entry.path.endsWith('.rs'))) { + const before = snapshot.readBlob(snapshot.baseTree.get(path)); + const after = snapshot.readBlob(snapshot.headTree.get(path)); + const base = measure(before), head = measure(after); + const exceeded = lineLengths(after).flatMap((n, i) => n > 100 ? [i + 1] : []); + const added = gate === '01' ? snapshot.addedLines(path) : new Set(); + const violation = violates(base, head); + fail ||= violation; + metrics.push({ path, base, head }); + diagnostics.push({ gate, path, base, head, status: violation ? 'FAIL' : 'PASS', + ...(gate === '01' ? { lines: exceeded.map(line => ({ line, added: added.has(line) })) } : {}), + reason: violation ? 'text debt increased' : 'text debt preserved or reduced', + remedy: gate === '01' ? 'wrap new long lines to at most 100 Unicode characters' : 'split the file or reduce it to at most 1000 lines' }); + } + snapshot.assertStableHead(); + return { gate, status: fail ? 'FAIL' : 'PASS', exit: fail ? 1 : 0, + requestedRef: snapshot.requestedRef, refOid: snapshot.refOid, baseOid: snapshot.baseOid, + headOid: snapshot.headOid, changedRustFiles: metrics.length, metrics, diagnostics }; + } catch (e) { + return { gate, status: 'ERROR', exit: e.exit ?? 3, baseOid: snapshot.baseOid, headOid: snapshot.headOid, + diagnostics: [...diagnostics, { gate, reason: e.message, remedy: 'repair inputs and rerun; no verdict available' }] }; + } +} diff --git a/scripts/sanitizing_rust_scan.mjs b/scripts/sanitizing_rust_scan.mjs new file mode 100644 index 000000000..e95d6d29a --- /dev/null +++ b/scripts/sanitizing_rust_scan.mjs @@ -0,0 +1,391 @@ +// Finite lexical Rust analysis, not type resolution or general macro expansion. +import { GateError } from './sanitizing_baseline.mjs'; +const identStart = /[\p{ID_Start}_]/u, identPart = /[\p{ID_Continue}_]/u; +const opens = { '(': ')', '[': ']', '{': '}' }; +const closes = new Set(Object.values(opens)); +function error(path, line, reason) { throw new GateError(`${path}:${line}: ${reason}; review Rust syntax`); } + +// Rust escapes, rather than JSON/JavaScript string escapes. +export function decodeRustString(raw, byte = false) { + const prefix = /^(?:b)?r(#+|)"/.exec(raw); + if (prefix) return raw.slice(prefix[0].length, -prefix[1].length - 1); + const source = raw.slice(byte ? 2 : 1, -1); + let value = '', opaque = false; + for (let i = 0; i < source.length; i++) { + const c = source[i]; + if (c.charCodeAt(0) !== 92) { value += c; continue; } + const e = source[++i], simple = { n: '\n', r: '\r', t: '\t', '0': '\0', [String.fromCharCode(92)]: String.fromCharCode(92), '"': '"', "'": "'" }; + if (Object.hasOwn(simple, e)) value += simple[e]; + else if (e === 'x') { + const hex = source.slice(i + 1, i + 3); + if (!/^[a-f\d]{2}$/i.test(hex)) throw new GateError('invalid Rust hex escape'); + const code = parseInt(hex, 16); + if (!byte && code > 127) throw new GateError('non-ASCII Rust string hex escape'); + if (byte && code > 127) opaque = true; // Still validate all remaining escapes. + value += String.fromCharCode(code); i += 2; + } else if (e === 'u' && source[i + 1] === '{' && !byte) { + const end = source.indexOf('}', i + 2), hex = source.slice(i + 2, end).replaceAll('_', ''); + if (end < 0 || !/^[a-f\d]{1,6}$/i.test(hex)) throw new GateError('invalid Rust Unicode escape'); + const code = parseInt(hex, 16); + if (code > 0x10ffff || code >= 0xd800 && code <= 0xdfff) throw new GateError('invalid Rust Unicode scalar'); + value += String.fromCodePoint(code); i = end; + } else if (e === '\n' || e === '\r' && source[i + 1] === '\n') { + if (e === '\r') i++; + while (/[\t\n\r ]/.test(source[i + 1] ?? '') && i + 1 < source.length) i++; + } else throw new GateError('invalid Rust escape'); + } + return opaque || byte && /[^\x00-\x7f]/.test(value) ? null : value; +} +export function lexRust(path, text) { + const tokens = [], groups = [], stack = []; let i = 0, line = 1; + function add(kind, start, end, tokenLine, extra = {}) { + const token = { kind, text: text.slice(start, end), start, end, line: tokenLine, index: tokens.length, ...extra }; + tokens.push(token); return token; + } + function advance(end) { for (; i < end; i++) if (text[i] === '\n') line++; } + while (i < text.length) { + const start = i, tokenLine = line, c = String.fromCodePoint(text.codePointAt(i)); + if (/\s/.test(c)) { advance(i + 1); continue; } + if (text.startsWith('//', i)) { let end = text.indexOf('\n', i); if (end < 0) end = text.length; advance(end); continue; } + if (text.startsWith('/*', i)) { + i += 2; let depth = 1; + while (i < text.length && depth) { + if (text.startsWith('/*', i)) { depth++; advance(i + 2); } + else if (text.startsWith('*/', i)) { depth--; advance(i + 2); } + else advance(i + 1); + } + if (depth) error(path, tokenLine, 'unclosed block comment'); continue; + } + const raw = /^(?:br|r)(#*)"/.exec(text.slice(i)); + if (raw) { + const suffix = '"' + raw[1], end = text.indexOf(suffix, i + raw[0].length); + if (end < 0) error(path, line, 'unclosed raw string'); + advance(end + suffix.length); + const byte = raw[0].startsWith('b'); + const value = decodeRustString(text.slice(start, i), byte); + add(byte ? 'byteString' : 'string', start, i, tokenLine, { value: byte && /[^\x00-\x7f]/.test(value) ? null : value, opaque: byte && /[^\x00-\x7f]/.test(value) }); continue; + } + if (c === '"' || c === 'b' && text[i + 1] === '"') { + const byte = c === 'b'; advance(i + (byte ? 2 : 1)); let closed = false; + while (i < text.length) { + if (text.charCodeAt(i) === 92) { advance(Math.min(i + 2, text.length)); continue; } + if (text[i] === '"') { advance(i + 1); closed = true; break; } + advance(i + 1); + } + if (!closed) error(path, tokenLine, 'unclosed string'); + let value; try { value = decodeRustString(text.slice(start, i), byte); } catch (e) { error(path, tokenLine, e.message); } + add(byte ? 'byteString' : 'string', start, i, tokenLine, { value, opaque: value === null }); continue; + } + if (c === "'" || c === 'b' && text[i + 1] === "'") { + const byte = c === 'b', quote = i + (byte ? 1 : 0); let end = quote + 1; + if (text.charCodeAt(end) === 92) { + end++; + if (text[end] === 'u' && text[end + 1] === '{') { const close = text.indexOf('}', end + 2); end = close < 0 ? text.length : close + 1; } + else end += text[end] === 'x' ? 3 : 1; + } else end += (text.codePointAt(end) ?? 0) > 0xffff ? 2 : 1; + if (text[end] === "'") { + const content = text.slice(quote + 1, end); + try { decodeRustString((byte ? 'b"' : '"') + content + '"', byte); } + catch (e) { error(path, tokenLine, e.message); } + advance(end + 1); add(byte ? 'byteChar' : 'char', start, i, tokenLine); continue; + } + const life = /^'[\p{ID_Start}_][\p{ID_Continue}_]*/u.exec(text.slice(i)); + if (!byte && life) { advance(i + life[0].length); add('lifetime', start, i, tokenLine); continue; } + error(path, tokenLine, 'invalid or unclosed character'); + } + const rawIdent = /^r#([\p{ID_Start}_][\p{ID_Continue}_]*)/u.exec(text.slice(i)); + if (rawIdent || identStart.test(c)) { + if (rawIdent) advance(i + rawIdent[0].length); + else { advance(i + c.length); while (i < text.length && identPart.test(String.fromCodePoint(text.codePointAt(i)))) advance(i + (text.codePointAt(i) > 0xffff ? 2 : 1)); } + add('ident', start, i, tokenLine, { name: text.slice(start, i).replace(/^r#/, '') }); continue; + } + if (/\d/.test(c)) { + // A tuple index cannot absorb the following projection, call or range. + const numeric = tokens.at(-1)?.text === '.' ? /^\d[\d_]*/ : + /^(?:0(?:x[\da-fA-F_]+|o[0-7_]+|b[01_]+)|\d[\d_]*(?:\.(?!\.|[_\p{ID_Start}])[\d_]*)?(?:[eE][+-]?[\d_]+)?)(?:[_\p{ID_Start}][\p{ID_Continue}_]*)?/u; + advance(i + numeric.exec(text.slice(i))[0].length); + add('number', start, i, tokenLine); continue; + } + const punctuation = ['::', '=>', '->', '==', '!=', '+=', '-=', '*=', '/=', '..=', '..', '&&', '||'].find(p => text.startsWith(p, i)) ?? c; + advance(i + punctuation.length); const token = add('punct', start, i, tokenLine); + if (opens[c]) stack.push(token.index); + else if (closes.has(c)) { + const open = stack.pop(); + if (open === undefined || opens[tokens[open].text] !== c) error(path, tokenLine, 'mismatched delimiter'); + tokens[open].mate = token.index; token.mate = open; groups.push({ start: open, end: token.index, kind: tokens[open].text }); + } + } + if (stack.length) error(path, tokens[stack.at(-1)].line, 'unclosed delimiter'); + return { tokens, groups }; +} +function splitTop(tokens, separator = ',') { + const result = []; let start = 0; + for (let i = 0; i < tokens.length; i++) { + if (opens[tokens[i].text]) { const mate = tokens.findIndex(t => t.index === tokens[i].mate); if (mate >= i) i = mate; } + else if (tokens[i].text === separator) { if (i > start) result.push(tokens.slice(start, i)); start = i + 1; } + } + if (start < tokens.length) result.push(tokens.slice(start)); return result; +} +export function parseMeta(tokens) { + let i = 0; + const fail = () => { throw new GateError(`line ${tokens[i]?.line ?? tokens.at(-1)?.line ?? 1}: invalid attribute meta`); }; + function parse() { + const absolute = tokens[i]?.text === '::'; if (absolute) i++; + if (tokens[i]?.kind !== 'ident') fail(); + let name = (absolute ? '::' : '') + tokens[i++].name; + while (tokens[i]?.text === '::') { i++; if (tokens[i]?.kind !== 'ident') fail(); name += '::' + tokens[i++].name; } + const node = { name, args: null }; + if (tokens[i]?.text === '=') { + i++; const begin = i; + while (i < tokens.length && ![',', ')'].includes(tokens[i].text)) { + if (opens[tokens[i].text]) { + const end = tokens.findIndex(t => t.index === tokens[i].mate); + if (end < i) fail(); i = end + 1; + } else i++; + } + if (begin === i) fail(); + const value = tokens.slice(begin, i); + node.value = value.length === 1 ? value[0].value ?? value[0].text : value.map(t => t.text).join(''); + node.valueTokens = value; + } else if (tokens[i]?.text === '(') { + i++; node.args = []; + while (tokens[i]?.text !== ')') { + if (!tokens[i]) fail(); + // cfg_attr conditions may contain literal key/value leaves. + node.args.push(parse()); + if (tokens[i]?.text === ')') break; + if (tokens[i++]?.text !== ',') fail(); + } + i++; + } + return node; + } + const meta = parse(); if (i !== tokens.length) fail(); return meta; +} +export function impliesTest(meta) { + if (meta.name === 'test' && meta.args === null && meta.value === undefined) return true; + if (meta.name === 'all') return !!meta.args?.some(impliesTest); + if (meta.name === 'any') return !!meta.args?.length && meta.args.every(impliesTest); + return false; +} +function attributeSignals(meta) { + return [meta, ...(meta.name === 'cfg_attr' ? (meta.args ?? []).slice(1).flatMap(attributeSignals) : [])]; +} +const testPaths = new Set(['test', 'tokio::test', 'async_std::test']); +const itemKinds = new Set(['fn', 'mod', 'struct', 'enum', 'union', 'trait', 'impl', 'const', 'static', 'type', 'use', 'extern', 'macro_rules']); +const simplePath = tokens => { + const absolute = tokens[0]?.text === '::', parts = absolute ? tokens.slice(1) : tokens; + return parts.length > 0 && parts.length % 2 === 1 && parts.every((t, i) => i % 2 === 0 ? t.kind === 'ident' : t.text === '::') + ? (absolute ? '::' : '') + parts.filter(t => t.kind === 'ident').map(t => t.name).join('::') : null; +}; +function imports(tokens, prefix = []) { + if (tokens[0]?.text === '::') tokens = tokens.slice(1); + const open = tokens.findIndex(t => t.text === '{'); + if (open >= 0) { + const head = tokens.slice(0, open).filter(t => t.kind === 'ident').map(t => t.name); + return splitTop(tokens.slice(open + 1, -1)).flatMap(part => imports(part, [...prefix, ...head])); + } + const as = tokens.findIndex(t => t.name === 'as'); + const parts = (as < 0 ? tokens : tokens.slice(0, as)).filter(t => t.kind === 'ident' || t.text === '*').map(t => t.name ?? t.text); + const self = parts.at(-1) === 'self'; if (self) parts.pop(); + const full = [...prefix, ...parts]; + return [{ name: as < 0 ? full.at(-1) : tokens[as + 1]?.name, target: full.join('::'), glob: full.at(-1) === '*' }]; +} +export function resolvePath(input, scope) { + const at = typeof input === 'string' ? Infinity : input[0]?.start ?? Infinity; + const text = typeof input === 'string' ? input : simplePath(input); + if (!text) return { path: null, ambiguous: true, reason: 'non-simple path' }; + if (text.startsWith('::')) return { path: text.slice(2), ambiguous: false }; + const parts = text.replace(/^::/, '').split('::'); let binding; + for (let s = scope; s; s = s.parent) { + if (s.bindings.has(parts[0])) { + binding = s.bindings.get(parts[0]); + while (binding && binding.start > at) binding = binding.previous; + if (binding) break; + } + if (s.globs.length) return { path: text, ambiguous: true, reason: 'glob import may bind path' }; + } + if (!binding) return { path: parts.join('::'), ambiguous: false }; + if (binding.ambiguous) return { path: binding.target ?? text, ambiguous: true, reason: binding.reason ?? 'ambiguous binding', binding }; + if (!binding.target) return { path: null, value: binding.value, ambiguous: false, shadowed: binding.value === undefined, binding }; + // Exactly one local path/constant alias hop; import qualification is preserved. + let target = binding.target; + if (binding.kind !== 'import') { + const first = target.split('::')[0]; let other; + for (let s = binding.scope; s; s = s.parent) { if (s.bindings.has(first)) { other = s.bindings.get(first); break; } } + if (other === binding || other?.kind !== 'import' && other?.target) + return { path: text, ambiguous: true, reason: 'cyclic or multi-hop constant/path alias', binding }; + if (other?.ambiguous) return { path: text, ambiguous: true, reason: 'ambiguous alias target', binding }; + if (other?.value !== undefined) return { path: null, value: other.value, ambiguous: false, binding: other }; + if (other?.target) target = other.target + target.slice(first.length); + else if (other && other.kind !== 'import') return { path: null, shadowed: true, ambiguous: false, binding }; + } + return { path: [target, ...parts.slice(1)].join('::').replace(/^::/, ''), ambiguous: false, binding }; +} + +export function scanRust(path, text) { + const { tokens, groups } = lexRust(path, text); + const root = { id: 0, start: 0, end: text.length, parent: null, bindings: new Map(), imports: [], globs: [], attributes: [] }; + const scopes = [root], items = [], attributes = [], diagnostics = []; + let current = root; + for (const token of tokens) { + token.scope = current; + if (token.text === '{') { + const scope = { id: scopes.length, start: token.start, end: tokens[token.mate].end, open: token.index, + parent: current, bindings: new Map(), imports: [], globs: [], attributes: [] }; + scopes.push(scope); current = scope; + } else if (token.text === '}') current = current.parent; + } + const macroGroups = groups.filter(g => tokens[g.start - 1]?.text === '!' || tokens[g.start - 2]?.text === '!'); + const inMacro = index => macroGroups.some(g => index > g.start && index < g.end); + // Mask comments by rebuilding only code tokens; retain whitespace/line offsets. + const code = text.split('').map(c => c === '\n' || c === '\r' ? c : ' '); + for (const token of tokens.filter(t => !['string', 'byteString', 'char', 'byteChar'].includes(t.kind))) + for (let i = token.start; i < token.end; i++) code[i] = text[i]; + const attrEnds = new Map(); + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].text !== '#' || inMacro(i)) continue; + const inner = tokens[i + 1]?.text === '!', open = i + (inner ? 2 : 1); + if (tokens[open]?.text !== '[') error(path, tokens[i].line, 'attribute without brackets'); + const end = tokens[open].mate, body = tokens.slice(open + 1, end); + // Rust attributes may contain token expressions (derive/path/doc); strictly parse cfg/test meta. + const relevant = ['cfg', 'cfg_attr'].includes(body[0]?.name); + let meta; + if (relevant) meta = parseMeta(body); + else { const call = body.findIndex(t => !['ident'].includes(t.kind) && t.text !== '::'); meta = { name: simplePath(call < 0 ? body : body.slice(0, call)) ?? body[0]?.name, args: null }; } + if (!meta.name) error(path, tokens[i].line, 'invalid attribute path'); + if (meta.name === 'cfg' && meta.args?.length !== 1) error(path, tokens[i].line, 'cfg requires one predicate'); + if (meta.name === 'cfg_attr' && (meta.args?.length ?? 0) < 2) error(path, tokens[i].line, 'cfg_attr requires predicate and attribute'); + const attr = { start: tokens[i].start, end: tokens[end].end, line: tokens[i].line, inner, meta, + signals: attributeSignals(meta), scope: tokens[i].scope, first: i, last: end }; + attributes.push(attr); attrEnds.set(end, attr); if (inner) attr.scope.attributes.push(attr); + i = end; + } + const insideAttribute = index => attributes.some(a => index >= a.first && index <= a.last); + const bind = (scope, name, data) => { + if (!name) return; + const prior = scope.bindings.get(name); + if (prior && prior.kind !== 'shadow' && data.kind !== 'shadow' && data.kind !== 'local') data = { ...data, ambiguous: true, reason: 'duplicate binding' }; + scope.bindings.set(name, { ...data, name, scope, previous: prior }); + }; + const genericConsts = new Set(); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i], kind = token.name; + if (!itemKinds.has(kind) || insideAttribute(i) || inMacro(i) || tokens[i - 1]?.text === '::' || tokens[i - 1]?.text === '.') continue; + if (['fn', 'mod', 'struct', 'enum', 'union', 'trait', 'type'].includes(kind) && tokens[i + 1]?.kind !== 'ident') continue; + if (kind === 'const' && (tokens[i + 1]?.kind !== 'ident' || tokens[i - 1]?.text === '*')) continue; + if (kind === 'const' && genericConsts.has(i)) continue; + if (items.some(item => item.kind === 'fn' && i > item.first && token.start < item.bodyStart)) continue; + // const qualifiers and generic parameters are not standalone items. + if (kind === 'const' && ['fn', 'unsafe', 'extern'].includes(tokens[i + 1]?.name)) continue; + if (kind === 'extern' && tokens[i + 1]?.name !== 'crate' && tokens[i + 1]?.text !== '{') continue; + if (kind === 'use' && tokens[i - 1]?.text === '<') continue; + let body = null, end = null, j = i + 1, angleDepth = 0; + const genericHeader = ['fn', 'struct', 'enum', 'union', 'trait', 'impl', 'type'].includes(kind); + for (; j < tokens.length; j++) { + const t = tokens[j]; + if (genericHeader) { + if (t.text === '<') angleDepth++; + else if (t.text === '>') angleDepth = Math.max(0, angleDepth - 1); + else if (angleDepth && t.name === 'const') genericConsts.add(j); + } + if (t.text === ';') { end = j; break; } + if (t.text === '{') { + if (angleDepth) { j = t.mate; continue; } + if (kind === 'use' || ['const', 'static', 'type'].includes(kind)) { j = t.mate; continue; } + body = j; end = t.mate; break; + } + if (t.text === '(' || t.text === '[') j = t.mate; + else if (t.text === '}') break; + } + if (end === null) error(path, token.line, `${kind} item not delimitable`); + let first = i; + while (first > 0 && ['pub', 'async', 'unsafe', 'default', 'const'].includes(tokens[first - 1].name)) first--; + if (tokens[first - 1]?.text === ')' && tokens[tokens[first - 1].mate - 1]?.name === 'pub') first = tokens[first - 1].mate - 1; + const attached = []; + while (attrEnds.has(first - 1) && !attrEnds.get(first - 1).inner) { const a = attrEnds.get(first - 1); attached.unshift(a); first = a.first; } + const name = ['impl', 'use', 'extern'].includes(kind) ? null : tokens[i + (tokens[i + 1]?.name === 'mut' || tokens[i + 1]?.text === '!' ? 2 : 1)]?.name; + const bodyScope = body === null ? null : scopes.find(s => s.open === body); + const item = { kind, name, start: tokens[first].start, end: tokens[end].end, line: token.line, + attributes: attached, scope: token.scope, bodyScope, bodyStart: body === null ? null : tokens[body].start, testOnly: false, first: i, last: end }; + items.push(item); + if (kind === 'use') { + for (const entry of imports(tokens.slice(i + 1, end))) { + token.scope.imports.push(entry); + if (entry.glob) token.scope.globs.push(entry); else bind(token.scope, entry.name, { ...entry, kind: 'import' }); + } + } else if (['const', 'static'].includes(kind)) { + const eq = tokens.slice(i, end).findIndex(t => t.text === '='); + const valueTokens = eq < 0 ? [] : tokens.slice(i + eq + 1, end); + const literal = valueTokens.length === 1 && ['string', 'byteString', 'number'].includes(valueTokens[0].kind) ? valueTokens[0] : null; + bind(token.scope, name, { kind: 'const', target: simplePath(valueTokens), value: literal?.value ?? literal?.text, + tokens: valueTokens, ambiguous: !literal && !simplePath(valueTokens), reason: 'non-simple constant expression' }); + } else if (name) bind(token.scope, name, { kind: 'shadow', target: null }); + if (kind === 'fn' && bodyScope) { + const params = tokens.findIndex((t, k) => k > i && k < (body ?? end) && t.text === '('); + if (params >= 0) for (const part of splitTop(tokens.slice(params + 1, tokens[params].mate))) { + const colon = part.findIndex(t => t.text === ':'); + const names = part.slice(0, colon < 0 ? part.length : colon).filter(t => t.kind === 'ident' && !['mut', 'ref'].includes(t.name)); + for (const n of names) bind(bodyScope, n.name, { kind: 'shadow', target: null }); + } + } + } + // Locals use a conservative scope map: uncertain reassignments/destructuring never erase signals. + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]; if (insideAttribute(i)) continue; + if (t.name === 'let') { + let end = i + 1; + for (; end < tokens.length && tokens[end].text !== ';'; end++) { + if (opens[tokens[end].text]) end = tokens[end].mate; + else if (tokens[end].text === '}') break; + } + const segment = tokens.slice(i + 1, end), eq = segment.findIndex(x => x.text === '='); + const lhs = segment.slice(0, eq < 0 ? segment.length : eq), colon = lhs.findIndex(x => x.text === ':'); + const pattern = (colon < 0 ? lhs : lhs.slice(0, colon)).filter(x => !['mut', 'ref'].includes(x.name)); + const rhs = eq < 0 ? [] : segment.slice(eq + 1); + const names = pattern.filter(x => x.kind === 'ident'), simple = pattern.length === 1 && names.length === 1; + const literal = rhs.length === 1 && ['string', 'byteString', 'number'].includes(rhs[0].kind) ? rhs[0] : null; + for (const n of names) { n.declaration = true; bind(t.scope, n.name, { kind: 'local', target: simple ? simplePath(rhs) : null, + value: literal?.value ?? literal?.text, tokens: rhs, ambiguous: !simple, + reason: !simple ? 'destructuring may shadow relevant alias' : undefined, start: t.start }); } + } else if (t.kind === 'ident' && !t.declaration && ['=', '+=', '-=', '*=', '/='].includes(tokens[i + 1]?.text) && tokens[i - 1]?.name !== 'let') { + for (let s = t.scope; s; s = s.parent) { + const binding = s.bindings.get(t.name); + if (binding && binding.kind !== 'const' && binding.start !== undefined && t.start > binding.start) { + binding.ambiguous = true; binding.reason = 'reassigned binding'; break; + } + } + } + } + const cfgTest = attrs => attrs.some(a => a.meta.name === 'cfg' && impliesTest(a.meta.args[0])); + const fileTest = /(?:(?:^|\/)(?:tests\/|tests\.rs$)|_tests\.rs$)/.test(path); + for (const item of items) { + let cfg = cfgTest(item.attributes); + for (let s = item.scope; s; s = s.parent) cfg ||= cfgTest(s.attributes); + for (const container of items) if (container !== item && container.start <= item.start && container.end >= item.end) cfg ||= cfgTest(container.attributes); + const directTest = item.kind === 'fn' && item.attributes.some(a => a.meta.name !== 'cfg_attr' && testPaths.has(resolvePath(a.meta.name, item.scope).path)); + item.cfgTest = cfg; item.directTest = directTest; item.testOnly = fileTest || cfg || directTest; + if (item.bodyScope) item.bodyScope.item = item; + } + const isTestOffset = offset => fileTest || scopes.some(s => offset >= s.start && offset < s.end && cfgTest(s.attributes)) || + items.some(item => offset >= item.start && offset < item.end && item.cfgTest || + item.directTest && offset >= item.bodyStart && offset < item.end); + for (const scope of scopes) for (const binding of scope.bindings.values()) if (binding.ambiguous) + diagnostics.push({ path, line: tokens.find(t => t.scope === scope && t.name === binding.name)?.line ?? 1, + reason: binding.reason, name: binding.name }); + const sites = compatibilitySites(path, text, tokens); + return { path, text, tokens, groups, items, scopes, root, attributes, diagnostics, sites, + code: code.join(''), isTestOffset, + productionTokens: tokens.filter(t => !isTestOffset(t.start)), + resolvePath(input, scope = root) { return resolvePath(input, scope); } }; +} + +// Compatibility ONLY. Pure mask and source.sinks grammar/ordering from frozen inputs: +// round3-dev-extract.cjs SHA256 7A6CCF4529F9420887A3BF8EEC8F7A811929FFB3C5598B8C773BD4720DD511A6 +// round3-dev-build.cjs SHA256 54AAA090354596DD1971833047FA84249546D046955759001071CC19583B8951 +// Deliberately preserves historical function matching; not the production lexer. +function mask(s){let a=s.split(''),i=0;const blank=(b,e)=>{for(let j=b;j65535?2:1;if(s[k]=="'"){i=k+1;blank(b,i);}else i++;}else i++;}}return a.join('');} +export function compatibilitySites(p,text,tokens=[]) { +const code=mask(text),lines=text.split('\n'),starts=[0];for(let i=0;i=0&&semim.index>=f.start&&m.index(a.end-a.start)-(b.end-b.start))[0];const item=f?f.name:'';const ordinal=sinks.filter(x=>x.item===item).length+1;let line=starts.findIndex(x=>x>m.index);if(line<0)line=starts.length;sinks.push({path:p,item,ordinal,line,offset:m.index,operation:m[0],evidence:lines[line-1].trim()});} +return sinks.map(site=>({...site,tokens:tokens.filter(t=>t.start>=site.offset&&t.start gitRunner(args, { ...options, env }); + t.after(() => { const resolved = fs.realpathSync(cwd), parent = fs.realpathSync(os.tmpdir()); + assert.equal(path.dirname(resolved), parent); fs.rmSync(resolved, { recursive: true }); }); + const git = (...args) => { + const r = runner(args, { cwd }); + assert.equal(r.error, undefined); assert.equal(r.signal, null); assert.equal(r.status, 0, r.stderr?.toString()); + return r.stdout.toString().trim(); + }; + git('init', '--initial-branch=main'); git('config', 'user.name', 'Sanitizing fixture'); + git('config', 'user.email', 'sanitizing@example.invalid'); git('config', 'core.autocrlf', 'false'); + git('config', 'commit.gpgsign', 'false'); + const write = (name, text) => { const target = path.join(cwd, name); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, text); }; + const commit = () => { git('add', '--all'); git('-c', 'core.hooksPath=', 'commit', '--allow-empty', '-m', 'fixture'); return git('rev-parse', 'HEAD'); }; + write('seed', 'seed'); const base = commit(); + return { cwd, git, write, commit, base, runner, snapshot: (baseRef = base, run = runner) => createSnapshot({ cwd, baseRef, runner: run }) }; +} +const text = (n, width = 1) => ('a'.repeat(width) + '\n').repeat(n); +function sequence(f, gate, name, before, mutation, expected = 1) { + f.write(name, before); const base = f.commit(); + assert.equal(gate(f.snapshot(base)).exit, 0); + f.write(name, mutation); f.commit(); const result = gate(f.snapshot(base)); + assert.equal(result.exit, expected, JSON.stringify(result)); + f.write(name, before); f.commit(); assert.equal(gate(f.snapshot(base)).exit, 0); + return result; +} +test('Unicode metrics match source: LF CRLF empty lone CR no final newline', () => { + assert.deepEqual(lineLengths(''), []); assert.deepEqual(lineLengths('a\r\nb\n'), [1, 1]); + assert.deepEqual(lineLengths('a\rb'), [3]); assert.deepEqual(lineLengths('😀'.repeat(100)), [100]); + assert.equal(fileMetrics(text(1000)).lines, 1000); assert.equal(fileMetrics(text(1001)).lines, 1001); +}); +test('01 controls: 100→101→100 astral, debt equal/down/up, comments and tests', t => { + const f = fixture(t); + for (const [before, after] of [['😀'.repeat(100), '😀'.repeat(101)], [text(1, 101), text(2, 101)], ['// ' + 'a'.repeat(97), '// ' + 'a'.repeat(98)], ['#[test]\nfn f() {}', '#[test]\n' + text(1, 101)]]) { + const r = sequence(f, lengthGate, 'tests.rs', before, after); + assert.ok(r.diagnostics[0].lines.some(x => x.added)); + } + sequence(f, lengthGate, 'debt.rs', text(2, 101), text(1, 101), 0); + sequence(f, lengthGate, 'crlf.rs', 'a'.repeat(100) + '\r\n', 'a'.repeat(101) + '\r\n'); +}); +test('02 controls: 1000→1001→1000 and existing debt equal/down/up', t => { + const f = fixture(t); + sequence(f, sizeGate, 'x.rs', text(1000), text(1001)); + sequence(f, sizeGate, 'x.rs', text(1001), text(1002)); + sequence(f, sizeGate, 'x.rs', text(1002), text(1001), 0); +}); +test('new paths cannot offset debt with deletions; rename copy delete; exact unusual paths', t => { + const f = fixture(t); const old = '-old space 😀.rs'; + f.write(old, text(1, 101)); const base = f.commit(); + f.git('mv', '--', old, '新 name.rs'); f.commit(); + const s = f.snapshot(base); assert.deepEqual(s.changed.map(x => x.status).sort(), ['A', 'D']); + assert.equal(lengthGate(s).exit, 1); + f.write('copy.rs', text(1001)); f.commit(); assert.equal(sizeGate(f.snapshot(base)).exit, 1); + f.git('rm', '--', '新 name.rs', 'copy.rs'); f.commit(); assert.equal(lengthGate(f.snapshot(base)).exit, 0); + // Colon is valid Git tree data even though Windows cannot materialize it. + const oid = f.git('hash-object', '-w', '--stdin'); + f.write('tree.bin', Buffer.concat([Buffer.from('100644 a:b.rs\0'), Buffer.from(oid, 'hex')])); + const tree = f.git('hash-object', '-t', 'tree', '-w', 'tree.bin'); + const colonCommit = f.git('commit-tree', tree, '-p', f.git('rev-parse', 'HEAD'), '-m', 'colon'); + f.git('update-ref', 'HEAD', colonCommit); + assert.equal(lengthGate(f.snapshot(base)).exit, 0); +}); +test('divergent merge-base and working tree edits ignored', t => { + const f = fixture(t); f.git('switch', '-c', 'side'); f.write('side.rs', 'x'); const side = f.commit(); + f.git('switch', 'main'); f.write('main.rs', 'y'); f.commit(); + const s = f.snapshot(side); assert.equal(s.baseOid, f.base); assert.equal(s.changed.length, 1); + f.write('main.rs', text(1, 101)); assert.equal(lengthGate(s).exit, 0); +}); +test('explicit empty/no changes, precedence, duplicate/unknown args and invalid ref', t => { + const f = fixture(t); assert.equal(lengthGate(f.snapshot()).changedRustFiles, 0); + assert.equal(parseArgs([], { GATE_BASE_REF: 'topic' }).baseRef, 'topic'); + assert.equal(parseArgs([], { GATE_BASE_REF: '' }).baseRef, 'origin/main'); + assert.equal(parseArgs(['--base-ref', 'x'], { GATE_BASE_REF: 'y' }).baseRef, 'x'); + for (const args of [['--bad'], ['--base-ref'], ['--base-ref', ''], ['--base-ref', 'x', '--base-ref', 'y']]) assert.throws(() => parseArgs(args), e => e.exit === 2); + assert.throws(() => f.snapshot('missing-ref'), e => e.exit === 3); +}); +test('HEAD drift overrides a passing measurement and partial FAIL', t => { + const f = fixture(t); f.write('x.rs', text(1, 101)); f.commit(); const s = f.snapshot(); + f.write('z', 'new'); f.commit(); assert.equal(lengthGate(s).exit, 3); +}); +test('special Git modes and UTF8 are ERROR, never absent', t => { + const f = fixture(t), blob = f.git('hash-object', '-w', '--stdin'); + for (const [mode, oid] of [['120000', blob], ['160000', f.base]]) { + f.git('update-index', '--add', '--cacheinfo', mode, oid, 'special.rs'); + f.git('-c', 'core.hooksPath=', 'commit', '-m', mode); + assert.equal(lengthGate(f.snapshot()).exit, 3); + } + f.git('update-index', '--force-remove', 'special.rs'); f.write('invalid.rs', Buffer.from([0xff])); f.commit(); + assert.equal(sizeGate(f.snapshot()).exit, 3); + assert.throws(() => parseTree(Buffer.from([0xff, 0])), e => e.exit === 3); + assert.throws(() => parseTree(Buffer.from('100644 blob bad\tx.rs\0')), e => e.exit === 3); + assert.throws(() => parseChanged(Buffer.from('M\0x.rs')), e => e.exit === 3); +}); +test('Git failures/spawn/signal/timeout/maxBuffer and missing object cannot give partial verdicts', t => { + const f = fixture(t); f.write('x.rs', 'x'); f.commit(); + for (const failure of [{status:1}, {status:null,signal:'SIGTERM'}, {status:null,error:Object.assign(new Error('timeout'),{code:'ETIMEDOUT'})}, {status:null,error:Object.assign(new Error('maxBuffer'),{code:'ENOBUFS'})}]) { + assert.throws(() => f.snapshot(f.base, () => ({stdout:Buffer.from('partial'),stderr:Buffer.from('failed'),...failure})), e => e.exit === 3); + } + assert.throws(() => f.snapshot(f.base, () => {throw Error('spawn');}), e => e.exit === 3); + const runner = (args, opts) => args[0] === 'cat-file' ? {status:1,stdout:Buffer.from('partial'),stderr:Buffer.from('missing object')} : gitRunner(args, opts); + assert.equal(lengthGate(f.snapshot(f.base,runner)).exit,3); +}); + +test('Git resolution selects a regular file in a directory with spaces without executing it', t => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sanitizing-git-')); + t.after(() => { assert.equal(path.dirname(fs.realpathSync(cwd)), fs.realpathSync(os.tmpdir())); + fs.rmSync(cwd, {recursive:true}); }); + const name = process.platform === 'win32' ? 'git.exe' : 'git'; + const nonfile = path.join(cwd,'nonfile'), space = path.join(cwd,'Git space 😀'); + fs.mkdirSync(nonfile); fs.mkdirSync(path.join(nonfile,name)); fs.mkdirSync(space); + const executable = path.join(space,name); fs.writeFileSync(executable,'not executed', {mode:0o755}); + const script = ` + import assert from 'node:assert/strict'; import cp from 'node:child_process'; + import {syncBuiltinESMExports} from 'node:module'; + cp.spawnSync = file => {assert.equal(file,${JSON.stringify(executable)}); return {status:0};}; + syncBuiltinESMExports(); + const {gitRunner} = await import(${JSON.stringify(new URL('./sanitizing_baseline.mjs', import.meta.url).href)}); + assert.equal(gitRunner([], {cwd:${JSON.stringify(cwd)},env:{PATH:${JSON.stringify([nonfile,space].join(path.delimiter))}}}).status,0); + `; + const r = spawnSync(process.execPath,['--input-type=module','-e',script], { + stdio:['ignore','pipe','pipe'],timeout:120_000,maxBuffer:64*1024*1024,windowsHide:true, + }); + assert.equal(r.error,undefined); assert.equal(r.signal,null); assert.equal(r.status,0,r.stderr?.toString()); +}); +test('Git resolution preserves env/options, PATH order and import without I/O', () => { + // Isolate builtin stubs in a Node child: no fake executable is ever launched. + const script = ` + import assert from 'node:assert/strict'; + import fs from 'node:fs'; + import path from 'node:path'; + import cp from 'node:child_process'; + import { syncBuiltinESMExports } from 'node:module'; + let available, attempted = [], calls = [], accessMode, failure; + fs.statSync = p => { + attempted.push(p); + if (failure) throw Object.assign(new Error('resolution failed'), {code: failure}); + if (p !== available) throw Object.assign(new Error('missing'), {code: 'ENOENT'}); + return {isFile: () => true}; + }; + fs.accessSync = (p, mode) => { assert.equal(p, available); accessMode = mode; }; + cp.spawnSync = (file, args, options) => { calls.push({file,args,options}); return {status:0}; }; + syncBuiltinESMExports(); + const {gitRunner, createSnapshot} = await import(${JSON.stringify(new URL('./sanitizing_baseline.mjs', import.meta.url).href)}); + assert.deepEqual(attempted, []); assert.deepEqual(calls, []); + const windows = process.platform === 'win32', cwd = process.cwd(); + const executable = windows ? 'git.exe' : 'git'; + const first = path.resolve(cwd, 'missing'), space = path.resolve(cwd, 'Git space 😀'); + available = path.join(space, executable); + const env = {PATH: [first, windows ? '"' + space + '"' : space].join(path.delimiter), + GIT_CONFIG_NOSYSTEM:'1', GIT_CONFIG_GLOBAL:'fixture-only', GIT_TEMPLATE_DIR:'fixture-template'}; + if (windows) env.Path = first; // PATH sorts before Path. + const args = ['rev-parse', 'HEAD']; + assert.equal(gitRunner(args, {cwd,env}).status, 0); + assert.deepEqual(attempted, [path.join(first, executable), available]); + assert.equal(accessMode, windows ? fs.constants.F_OK : fs.constants.X_OK); + assert.deepEqual(calls[0], {file:available,args,options:{cwd,env, + stdio:['ignore','pipe','pipe'],timeout:120000,maxBuffer:64*1024*1024,windowsHide:true}}); + if (windows) { const onlyPath = {Path:space}; gitRunner(args,{cwd,env:onlyPath}); assert.equal(calls.at(-1).options.env,onlyPath); } + available = path.join(cwd, executable); + gitRunner(args, {cwd,env:{PATH:''}}); assert.equal(calls.at(-1).file,available); + available = path.resolve(cwd, 'relative', executable); + gitRunner(args, {cwd,env:{PATH:'relative'}}); assert.equal(calls.at(-1).file,available); + const inherited = windows ? process.env.PATH : '/usr/bin:/bin'; + let inheritedDir = inherited.split(path.delimiter)[0]; + if (windows && inheritedDir.startsWith('"') && inheritedDir.endsWith('"')) inheritedDir = inheritedDir.slice(1,-1); + available = path.resolve(cwd,inheritedDir,executable); + gitRunner(args,{cwd,env:{}}); assert.equal(calls.at(-1).file,available); + available = undefined; const before = calls.length; + const missing = {cwd,env:{PATH:first}}; + assert.throws(() => gitRunner(args,missing), e => e.exit === 3 && e.message.includes('install Git/check PATH')); + assert.throws(() => createSnapshot({cwd,runner:a => gitRunner(a,missing)}), e => e.exit === 3); + failure = 'EIO'; + assert.throws(() => gitRunner(args,missing), e => e.exit === 3 && /resolution failed/.test(e.message)); + assert.equal(calls.length,before); + `; + const r = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, maxBuffer: 64 * 1024 * 1024, windowsHide: true, + }); + assert.equal(r.error, undefined); assert.equal(r.signal, null); assert.equal(r.status, 0, r.stderr?.toString()); +}); + +test('cfg truth table never cuts later production, cfg_attr remains production', () => { + for (const [cfg, excluded] of [['test',true],['all(test,unix)',true],['any(has_embedded_dist,test)',false],['not(test)',false],['any(all(test,unix),test)',true],['all()',false],['any()',false],['not(not(test))',false]]) { + const a=scanRust('x.rs',`#[cfg(${cfg})] mod m { fn hidden() {} } fn visible() {}`); + assert.equal(a.items.find(i=>i.name==='hidden').testOnly,excluded,cfg); + assert.equal(a.items.find(i=>i.name==='visible').testOnly,false,cfg); + } + const a=scanRust('x.rs','#[cfg_attr(unix,test)] fn f() {} fn production() {}'); + assert.equal(a.items[0].testOnly,false);assert.ok(a.attributes[0].signals.some(s=>s.name==='test')); +}); +test('inner cfg is lexical, async direct/aliased tests exclude body only, test paths', () => { + const a=scanRust('x.rs','use tokio::{test as tt}; mod m { #![cfg(all(test,unix))] fn a() {} } #[tt] async fn b() {} fn production() {}'); + assert.equal(a.items.find(i=>i.name==='a').testOnly,true);assert.equal(a.items.find(i=>i.name==='b').directTest,true); + assert.equal(a.items.find(i=>i.name==='production').testOnly,false); + const b=a.items.find(i=>i.name==='b');assert.equal(a.isTestOffset(b.bodyStart+1),true);assert.equal(a.isTestOffset(b.start),false); + for (const [paths, excluded] of [ + [['tests.rs','src/tests.rs','x_tests.rs','src/tests/x.rs'], true], + [['contest.rs','src/tests_extra/x.rs','a_tests.rs.bak'], false], + ]) for (const p of paths) { + const source = scanRust(p,'fn p(){}'); + assert.equal(source.items[0].testOnly,excluded,p); + assert.equal(source.productionTokens.length === 0,excluded,p); + } +}); +test('nested imports/self/as and nearest parameter/block bindings', () => { + const a=scanRust('x.rs','use std::{fs::{self as disk,File as F},process::Command as C}; fn f(disk: Thing) { disk::write(); {use tokio::fs as disk;disk::write();} }'); + assert.equal(a.resolvePath('F').path,'std::fs::File'); assert.equal(a.resolvePath('C').path,'std::process::Command'); + const calls=a.tokens.filter(t=>t.name==='disk'&&a.tokens[t.index+1]?.text==='::'); + assert.equal(a.resolvePath('disk::write',calls[0].scope).shadowed,true); + assert.equal(a.resolvePath('disk::write',calls[1].scope).path,'tokio::fs::write'); +}); +test('one-hop path aliases, destructuring/glob/reassignment/cyclic constants retain uncertainty', () => { + const a=scanRust('x.rs','use std::fs as disk; const A: &str = B; const B: &str = A; fn f(){let d = disk;d::write();let (x,y)=pair; x::write();let r=disk;r=disk;r::write();}'); + const fn=a.items.find(i=>i.name==='f').bodyScope; + assert.equal(a.resolvePath('d::write',fn).path,'std::fs::write');assert.equal(a.resolvePath('x::write',fn).ambiguous,true); + assert.equal(a.resolvePath('r::write',fn).ambiguous,true);assert.equal(a.resolvePath('A').ambiguous,true); + assert.equal(scanRust('x.rs','use unknown::*;fn f(){fs::write();}').resolvePath('fs::write').ambiguous,true); +}); +test('literals comments raw IDs chars/lifetimes preserve offsets and decode Rust escapes', () => { + const slash=String.fromCharCode(92); + const source='/* outer /* inner */ */ fn r#f<\'a>(x: &\'a str) { let a = r###"/*not comment*/"###; let b = "a'+slash+'x41'+slash+'u{1F600}"; let c = \'x\';let b = b"'+slash+'xFF"; }'; + const a=scanRust('x.rs',source);assert.ok(a.tokens.some(t=>t.kind==='lifetime'));assert.ok(a.tokens.some(t=>t.name==='f')); + assert.equal(a.tokens.find(t=>t.kind==='string'&&t.text.startsWith('"')).value,'aA😀'); + assert.ok(a.tokens.some(t=>t.kind==='byteString'&&t.opaque)); + for(const t of a.tokens)assert.equal(source.slice(t.start,t.end),t.text); + assert.equal(decodeRustString('"a'+slash+'\n b"'),'ab'); +}); +test('malformed lexer/meta/items fail closed; reversal restores scan', () => { + for(const bad of ['fn f(){','/* no end','fn f(){let s="no end;}','fn f(){let s=r##"bad"#;}','fn f(]{}','#[cfg(any(test,))] fn f(){','#[cfg()] fn f(){}','#[cfg_attr(test)] fn f(){}','fn f()']) { + assert.doesNotThrow(()=>scanRust('x.rs','fn f(){}')); + assert.throws(()=>scanRust('x.rs',bad),e=>e.exit===3,bad); + assert.doesNotThrow(()=>scanRust('x.rs','fn f(){}')); + } +}); +test('compatibility ordinals include tests and homonymous items before exclusions', () => { + const source='#[test] fn same(){fs::write("x",b"x");} mod m{fn same(){p.join("a");File::open("a");}}'; + const a=scanRust('x.rs',source);assert.deepEqual(a.sites.map(s=>s.ordinal),[1,2,3]); + assert.equal(a.sites[0].item,'same');assert.ok(a.sites[0].tokens.length);assert.equal(a.sites[0].evidence,source); + assert.equal(compatibilitySites('x.rs','// fs::write("x","")\nfn f(){ let s="fs::write"; }').length,0); +}); +// Documented finite limits: no type/reexport resolution or expansion of arbitrary macros. +test('limitations are signals, never a semantic proof', () => { + const a=scanRust('x.rs','fn f(){custom!{File::open("x")}; unrelated.open(); hidden_helper();}'); + assert.equal(a.sites.length,2); // Homonymous methods remain suspicious; hidden helper is not inferred. + assert.equal(a.resolvePath('reexport::File').path,'reexport::File'); +}); +test('Rust expression/type keywords and opaque macro tokens are not item declarations', () => { + const a=scanRust('x.rs','struct S { cb: fn(&str)->bool } fn f(p: *const u8) { const { assert!(true); } let x = p as *const u8; helper!{enum identifier ignored_any}; }'); + assert.deepEqual(a.items.map(i=>i.kind),['struct','fn']); +}); +test('temporal local shadowing, mutable initialization, literal constant aliases', () => { + const a=scanRust('x.rs','use std::fs; const NAME: &str = "a.json";fn f(){ fs::write();let fs=local;fs::write();let mut disk=std::fs;disk::write();let name=NAME;}'); + const calls=a.tokens.filter(t=>t.name==='fs'&&a.tokens[t.index+1]?.text==='::'&&a.tokens[t.index+2]?.name==='write'); + assert.equal(a.resolvePath(a.tokens.slice(calls[0].index,calls[0].index+3),calls[0].scope).path,'std::fs::write'); + assert.equal(a.resolvePath(a.tokens.slice(calls[1].index,calls[1].index+3),calls[1].scope).path,'local::write'); + const scope=a.items.find(i=>i.name==='f').bodyScope; + assert.equal(a.resolvePath('disk::write',scope).ambiguous,false);assert.equal(a.resolvePath('name',scope).value,'a.json'); +}); +test('missing merge-base/history and successful-but-truncated Git output are ERROR', t => { + const f=fixture(t);f.git('switch','--orphan','other');f.git('rm','-rf','--ignore-unmatch','.');f.write('other','x');f.commit(); + assert.throws(()=>f.snapshot(f.base),e=>e.exit===3); + f.git('switch','main'); + for(const command of ['ls-tree','diff']) { + const runner=(args,opts)=>args[0]===command?{status:0,stdout:Buffer.from('truncated'),stderr:Buffer.alloc(0)}:f.runner(args,opts); + assert.throws(()=>f.snapshot(f.base,runner),e=>e.exit===3); + } +}); +test('blob diff failure, malformed UTF8 path and executable regular blobs', t => { + const f=fixture(t);f.write('x.rs','x');const base=f.commit();f.write('x.rs','xx');f.commit(); + const runner=(args,opts)=>args[0]==='diff'&&args.includes('-U0')?{status:1,stdout:Buffer.from('partial'),stderr:Buffer.from('bad diff')}:f.runner(args,opts); + assert.equal(lengthGate(f.snapshot(base,runner)).exit,3); + const blob=f.git('rev-parse','HEAD:x.rs');f.git('update-index','--cacheinfo','100755',blob,'x.rs');f.git('-c','core.hooksPath=','commit','-m','mode'); + assert.equal(lengthGate(f.snapshot(base)).exit,0); + const malformed=Buffer.concat([Buffer.from('100644 blob '+blob+'\t'),Buffer.from([0xff,0])]); + assert.throws(()=>parseTree(malformed),e=>e.exit===3); + const n=f.git('rev-parse','HEAD');assert.equal(parseTree(Buffer.from('100644 blob '+blob+'\ta:b.rs\0')).get('a:b.rs').oid,blob);assert.ok(n); +}); +test('a copied long-line file fails independently; deleting the copy reverts to PASS', t => { + const f=fixture(t);f.write('original.rs',text(1,101));const base=f.commit();assert.equal(lengthGate(f.snapshot(base)).exit,0); + f.write('copy.rs',text(1,101));f.commit();assert.equal(lengthGate(f.snapshot(base)).exit,1); + f.git('rm','copy.rs');f.commit();assert.equal(lengthGate(f.snapshot(base)).exit,0); +}); +test('file root inner cfg and function-local module shadowing do not leak', () => { + const a=scanRust('x.rs','#![cfg(test)] fn a(){}');assert.equal(a.items[0].testOnly,true); + const b=scanRust('x.rs','use std::fs;fn f(){mod fs{} fs::write();}fn p(){fs::write();}'); + assert.equal(b.resolvePath('fs::write',b.items.find(i=>i.name==='f').bodyScope).shadowed,true); + assert.equal(b.resolvePath('fs::write',b.items.find(i=>i.name==='p').bodyScope).path,'std::fs::write'); +}); +test('ERROR dominates FAIL and exact path identity is preserved', t => { + const f=fixture(t);f.write('a.rs',text(1,101));f.write('z.rs',Buffer.from([0xff]));f.commit(); + assert.equal(lengthGate(f.snapshot()).exit,3); + const oid=f.git('rev-parse','HEAD:a.rs'); + const names=['A.rs','a.rs','é.rs','é.rs','-a b:😀.rs']; + const map=parseTree(Buffer.from(names.map(n=>'100644 blob '+oid+'\t'+n+'\0').join(''))); + assert.equal(map.size,names.length);for(const n of names)assert.ok(map.has(n)); +}); +test('opaque byte literals still validate escapes; Unicode identifiers and arbitrary raw hashes', () => { + const slash=String.fromCharCode(92); + assert.throws(()=>scanRust('x.rs','fn f(){let b=b"'+slash+'xFF'+slash+'q";}'),e=>e.exit===3); + const a=scanRust('x.rs','fn 𐐀(){let s=r########"raw"########;}');assert.equal(a.items[0].name,'𐐀'); +}); +test('cfg_attr expression-valued metadata stays production; invalid char escape errors', () => { + const a=scanRust('x.rs','#[cfg_attr(feature="x",doc=concat!("one","two"),test)] fn f(){}'); + assert.equal(a.items[0].testOnly,false);assert.ok(a.attributes[0].signals.some(s=>s.name==='test')); + const slash=String.fromCharCode(92);assert.throws(()=>lexRust('x.rs',"fn f(){let c='"+slash+"q';}"),e=>e.exit===3); +}); + +test('numeric tokens preserve ranges, tuple calls and Rust numeric literals', () => { + const source = 'fn f(){for i in 0..MAX { self.0.write_all(b); } for j in 1..=LIMIT { x.0.1.join("a"); state.1.push(v); }}'; + const a = scanRust('x.rs', source); + const range = a.tokens.findIndex(t => t.text === '0'); + assert.deepEqual(a.tokens.slice(range, range + 3).map(t => t.text), ['0', '..', 'MAX']); + const inclusive = a.tokens.findIndex(t => t.text === '..='); + assert.deepEqual(a.tokens.slice(inclusive - 1, inclusive + 2).map(t => t.text), ['1', '..=', 'LIMIT']); + for (const name of ['write_all', 'join', 'push']) { + assert.ok(a.productionTokens.some(t => t.name === name), name); + assert.ok(a.sites.find(s => s.operation.includes(name)).tokens.some(t => t.name === name), name); + } + assert.ok(a.tokens.some((t, i) => t.text === '0' && a.tokens[i + 1]?.text === '.' && a.tokens[i + 2]?.text === '1')); + for (const literal of ['1.5f32', '1e-3', '1E+3f64', '0x1F', '0xff_u8', '0b1010u8', '0o77', '1_000usize', '1.']) { + const tokens = lexRust('x.rs', literal).tokens; + assert.equal(tokens.length, 1, literal); assert.equal(tokens[0].kind, 'number'); assert.equal(tokens[0].text, literal); + } + for (const t of a.tokens) assert.equal(source.slice(t.start, t.end), t.text); +}); + +test('const generic parameters are not items; actual body constants stay visible', () => { + const sources = [ + 'impl Foo { const VALUE: usize = 1; fn a(){} }', + 'struct A { x: [u8; N] }', + 'struct Tuple([T; N]);', + 'trait A { const VALUE: usize; fn a(); }', + 'enum A { V([u8; N]) }', + 'union A { x: [u8; N] }', + 'type A = [u8; N];', + 'fn a() { const VALUE: usize = 1; }', + ]; + for (const source of sources) { + const a = scanRust('x.rs', source); + assert.ok(!a.items.some(i => i.kind === 'const' && i.name === 'N'), source); + if (source.includes('VALUE')) assert.ok(a.items.some(i => i.kind === 'const' && i.name === 'VALUE'), source); + if (source.includes('fn a')) assert.ok(a.items.some(i => i.kind === 'fn' && i.name === 'a'), source); + } + assert.throws(() => scanRust('x.rs', 'impl Foo { const VALUE: usize }'), e => e.exit === 3); +}); + +test('absolute test attributes resolve without shadowing or hiding later production', () => { + for (const attribute of ['::tokio::test', '::async_std::test']) { + const a = scanRust('x.rs', `mod tokio {} #[${attribute}] async fn t(){work();} fn production(){work();}`); + const t = a.items.find(i => i.name === 't'), p = a.items.find(i => i.name === 'production'); + assert.equal(t.directTest, true); assert.equal(a.isTestOffset(t.bodyStart + 1), true); + assert.equal(a.isTestOffset(t.start), false); assert.equal(p.testOnly, false); + assert.ok(a.productionTokens.some(token => token.name === 'production')); + const path = lexRust('x.rs', attribute).tokens; + assert.equal(a.resolvePath(path).path, attribute.slice(2)); + } + const a = scanRust('x.rs', '#[cfg_attr(unix,::tokio::test)] fn production(){}'); + assert.equal(a.items[0].testOnly, false); assert.ok(a.attributes[0].signals.some(s => s.name === '::tokio::test')); + assert.throws(() => scanRust('x.rs', '#[::] fn t(){}'), e => e.exit === 3); +});