// Source/config lint for repositories with no supported build: tracked JSON must // parse, tracked shell must pass `bash -n`, and no merge-conflict markers. // usage: node check-source.mjs (run at the repository root) import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { isUtf8 } from 'node:buffer'; import { jsonc } from './jsonc.mjs'; const entries = execFileSync('git', ['ls-files', '--stage', '-z'], { encoding: 'utf8', maxBuffer: 1 << 28 }) .split('\0').filter(Boolean).map(entry => [entry.slice(0, 6), entry.slice(entry.indexOf('\t') + 1)]); const files = entries.map(entry => entry[1]); const links = new Set(entries.filter(entry => entry[0] === '120000').map(entry => entry[1])); const bad = []; let json = 0, shell = 0, encrypted = 0, captures = 0; // Tracked Python is only compiled (never imported or run); vendored trees are not ours to gate. const python = []; const gitCryptHeader = Buffer.from('\0GITCRYPT\0'); for (const file of files) { // A tracked link is metadata, not configuration content. Never follow a link // into host files or interpret its target as JSON when Git materializes it as text. if (links.has(file)) continue; // .data holds raw command-response captures, including intentionally empty response files. // Secret scanning still examines the full repository history independently. if (file.startsWith('.data/')) { captures++; continue; } const isText = /\.(md|txt|ya?ml|json|sh|py|tf|toml|ini|cfg|conf)$/.test(file); const bytes = isText ? readFileSync(file) : null; // Respect git-crypt boundaries: ciphertext cannot be parsed as source/configuration. if (bytes?.subarray(0, gitCryptHeader.length).equals(gitCryptHeader)) { encrypted++; continue; } // DBeaver stores encrypted binary credentials under this documented .json filename. if (bytes && /(^|\/)\.dbeaver\/credentials-config\.json$/.test(file) && !isUtf8(bytes)) { encrypted++; continue; } const text = bytes?.toString('utf8'); if (/\.py$/.test(file) && !/(^|\/)(vendor|third_party|node_modules)\//.test(file)) python.push(file); if (/\.json$/.test(file) && !/(^|\/)(tsconfig[^/]*|\.eslintrc|\.babelrc|devcontainer)\.json$/.test(file)) { json++; const vscode = /(^|\/)(\.vscode|vscode)\/(settings|extensions|launch|tasks)\.json$/.test(file); try { if (vscode) jsonc(text); else JSON.parse(text); } catch { bad.push(`${file}: invalid ${vscode ? 'JSONC' : 'JSON'}`); } } else if (/\.sh$/.test(file)) { shell++; const run = spawnSync('bash', ['-n', file], { encoding: 'utf8' }); if (run.status !== 0) bad.push(`${file}: shell syntax error ${run.stderr.trim()}`); } if (/\.(md|txt|ya?ml|json|sh|py|tf|toml|ini|cfg|conf)$/.test(file)) { if (/^<{7} .+$/m.test(text) && /^>{7} .+$/m.test(text)) bad.push(`${file}: merge-conflict markers`); } } if (python.length) { // compile() parses and byte-compiles in memory: no execution, no import, no __pycache__. const program = 'import sys\n' + 'for p in sys.stdin.buffer.read().split(b"\\0"):\n' + ' if not p: continue\n' + ' path = p.decode("utf-8", "surrogateescape")\n' + ' try:\n' + ' compile(open(path, "rb").read(), path, "exec", dont_inherit=True)\n' + ' except (SyntaxError, ValueError) as e:\n' + ' print(f"{path}: {type(e).__name__}: {e}")\n'; const run = spawnSync(process.env.CHECK_SOURCE_PYTHON || 'python3', ['-I', '-B', '-c', program], { input: python.join('\0'), encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } }); if (run.error) bad.push(`python3 is required to check ${python.length} tracked .py files: ${run.error.message}`); else if (run.status !== 0) bad.push(`python checker failed: ${run.stderr.trim()}`); else for (const line of run.stdout.split('\n').filter(Boolean)) bad.push(`${line} (python syntax)`); } console.log(`source: ${files.length} tracked files, ${json} json, ${shell} shell, ${python.length} python, ${encrypted} encrypted, ${captures} raw captures`); if (bad.length) { for (const line of bad) console.error(`source: ${line}`); process.exit(1); }