|
| 1 | +/** |
| 2 | + * Lint guardrails the fleet enforces beyond what oxlint covers natively. |
| 3 | + * |
| 4 | + * Five checks, one pass: |
| 5 | + * |
| 6 | + * 1. **Status-symbol emoji** (✓ ✔ ❌ ✗ ⚠ ⚠️ ❗ ✅ ❎ ☑) — banned. |
| 7 | + * The `@socketsecurity/lib/logger` package owns the visual prefix |
| 8 | + * via `logger.success()` / `logger.fail()` / `logger.warn()` etc. |
| 9 | + * Hand-rolling the symbols fragments the visual style and bypasses |
| 10 | + * theme-aware color. |
| 11 | + * 2. **`console.log` / `console.error` / `console.warn` / `console.info` |
| 12 | + * / `console.debug` / `console.trace`** — banned. Use the logger. |
| 13 | + * 3. **Inline `getDefaultLogger().<method>()`** — banned. The logger |
| 14 | + * must be hoisted at the top of the file: |
| 15 | + * `const logger = getDefaultLogger()` |
| 16 | + * then `logger.success(...)`. Inline calls re-resolve the logger |
| 17 | + * every invocation and read inconsistently. |
| 18 | + * 4. **Dynamic `import()` in non-bundled code** — banned. Scripts under |
| 19 | + * `scripts/` run directly via `node`; nothing bundles them, so a |
| 20 | + * dynamic import only adds a runtime async hop for no resolution win. |
| 21 | + * Use static ES6 imports. Allowed inside `src/` (which gets bundled) |
| 22 | + * and inside `.config/` bundler configs. |
| 23 | + * |
| 24 | + * (TypeScript `any` is enforced by oxlint's `typescript/no-explicit-any` |
| 25 | + * rule — kept in `.oxlintrc.json` so it benefits from the language-aware |
| 26 | + * AST. Don't duplicate that here.) |
| 27 | + * |
| 28 | + * Why a custom check instead of oxlint plugins: the rules above need |
| 29 | + * either custom matchers (the inline-logger hoist requirement) or |
| 30 | + * conditional scope (dynamic-import bans only outside the bundled tree) |
| 31 | + * that oxlint's built-in rule set doesn't express. A small TS scanner |
| 32 | + * is cheaper than a full oxlint plugin and runs in the existing |
| 33 | + * scripts/check.mts pipeline. |
| 34 | + * |
| 35 | + * Usage: |
| 36 | + * import { checkLoggerGuardrails } from '.../_shared/scripts/logger-guardrails.mts' |
| 37 | + * const { violations } = await checkLoggerGuardrails({ cwd: process.cwd() }) |
| 38 | + * if (violations.length) { process.exitCode = 1 } |
| 39 | + */ |
| 40 | +import { existsSync, readFileSync } from 'node:fs' |
| 41 | +import path from 'node:path' |
| 42 | + |
| 43 | +import fastGlob from 'fast-glob' |
| 44 | + |
| 45 | +export type GuardrailReason = |
| 46 | + | 'status-emoji' |
| 47 | + | 'console-call' |
| 48 | + | 'inline-logger' |
| 49 | + | 'dynamic-import' |
| 50 | + |
| 51 | +export type GuardrailViolation = { |
| 52 | + readonly file: string |
| 53 | + readonly line: number |
| 54 | + readonly column: number |
| 55 | + readonly snippet: string |
| 56 | + readonly reason: GuardrailReason |
| 57 | +} |
| 58 | + |
| 59 | +export type CheckLoggerGuardrailsOptions = { |
| 60 | + /** Repo root. Defaults to process.cwd(). */ |
| 61 | + readonly cwd?: string |
| 62 | + /** Globs to scan, relative to cwd. */ |
| 63 | + readonly include?: readonly string[] |
| 64 | + /** Globs to skip. */ |
| 65 | + readonly exclude?: readonly string[] |
| 66 | + /** File extensions to scan. */ |
| 67 | + readonly extensions?: readonly string[] |
| 68 | + /** |
| 69 | + * Globs that ARE bundled. Dynamic `import()` is allowed inside these |
| 70 | + * (the bundler resolves the import statically at build time). Default |
| 71 | + * is `src/**` + `.config/**` (bundler configs). |
| 72 | + */ |
| 73 | + readonly bundledRoots?: readonly string[] |
| 74 | +} |
| 75 | + |
| 76 | +export type CheckLoggerGuardrailsResult = { |
| 77 | + readonly violations: readonly GuardrailViolation[] |
| 78 | + readonly fileCount: number |
| 79 | +} |
| 80 | + |
| 81 | +const DEFAULT_INCLUDE = ['scripts/**/*', 'src/**/*', 'lib/**/*', '.config/**/*'] |
| 82 | +const DEFAULT_EXCLUDE = [ |
| 83 | + '**/dist/**', |
| 84 | + '**/node_modules/**', |
| 85 | + '**/coverage/**', |
| 86 | + '**/.cache/**', |
| 87 | + '**/test/fixtures/**', |
| 88 | + '**/test/packages/**', |
| 89 | + '**/*.d.ts', |
| 90 | + '**/*.d.mts', |
| 91 | + '**/upstream/**', |
| 92 | +] |
| 93 | +const DEFAULT_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'] |
| 94 | +const DEFAULT_BUNDLED_ROOTS = ['src/', '.config/'] |
| 95 | + |
| 96 | +const STATUS_EMOJI = ['✓', '✔', '❌', '✗', '⚠', '⚠️', '❗', '✅', '❎', '☑'] |
| 97 | + |
| 98 | +const CONSOLE_CALL_RE = |
| 99 | + /\bconsole\s*\.\s*(?:log|error|warn|info|debug|trace)\s*\(/g |
| 100 | + |
| 101 | +const INLINE_LOGGER_RE = /\bgetDefaultLogger\s*\(\s*\)\s*\.\s*[a-zA-Z_$]/g |
| 102 | + |
| 103 | +const DYNAMIC_IMPORT_RE = /(?<![a-zA-Z_$])import\s*\(/g |
| 104 | + |
| 105 | +function isInBundledRoot( |
| 106 | + relativePath: string, |
| 107 | + bundledRoots: readonly string[], |
| 108 | +): boolean { |
| 109 | + const normalized = relativePath.split(path.sep).join('/') |
| 110 | + return bundledRoots.some(root => normalized.startsWith(root)) |
| 111 | +} |
| 112 | + |
| 113 | +function isCommentLine(trimmed: string): boolean { |
| 114 | + return ( |
| 115 | + trimmed.startsWith('//') || |
| 116 | + trimmed.startsWith('*') || |
| 117 | + trimmed.startsWith('/*') |
| 118 | + ) |
| 119 | +} |
| 120 | + |
| 121 | +export async function checkLoggerGuardrails( |
| 122 | + options: CheckLoggerGuardrailsOptions = {}, |
| 123 | +): Promise<CheckLoggerGuardrailsResult> { |
| 124 | + const cwd = options.cwd ?? process.cwd() |
| 125 | + const include = options.include ?? DEFAULT_INCLUDE |
| 126 | + const exclude = options.exclude ?? DEFAULT_EXCLUDE |
| 127 | + const extensions = options.extensions ?? DEFAULT_EXTENSIONS |
| 128 | + const bundledRoots = options.bundledRoots ?? DEFAULT_BUNDLED_ROOTS |
| 129 | + |
| 130 | + const files = await fastGlob(include as string[], { |
| 131 | + absolute: true, |
| 132 | + cwd, |
| 133 | + ignore: exclude as string[], |
| 134 | + onlyFiles: true, |
| 135 | + }) |
| 136 | + |
| 137 | + const matched = files.filter(file => |
| 138 | + extensions.some(ext => file.endsWith(ext)), |
| 139 | + ) |
| 140 | + |
| 141 | + const violations: GuardrailViolation[] = [] |
| 142 | + |
| 143 | + for (const file of matched) { |
| 144 | + if (!existsSync(file)) { |
| 145 | + continue |
| 146 | + } |
| 147 | + const relative = path.relative(cwd, file) |
| 148 | + const inBundled = isInBundledRoot(relative, bundledRoots) |
| 149 | + const content = readFileSync(file, 'utf8') |
| 150 | + const lines = content.split('\n') |
| 151 | + |
| 152 | + for (const [index, line] of lines.entries()) { |
| 153 | + const trimmed = line.trimStart() |
| 154 | + if (isCommentLine(trimmed)) { |
| 155 | + continue |
| 156 | + } |
| 157 | + |
| 158 | + // (1) Status-symbol emoji. |
| 159 | + for (const emoji of STATUS_EMOJI) { |
| 160 | + const col = line.indexOf(emoji) |
| 161 | + if (col >= 0) { |
| 162 | + violations.push({ |
| 163 | + column: col + 1, |
| 164 | + file: relative, |
| 165 | + line: index + 1, |
| 166 | + reason: 'status-emoji', |
| 167 | + snippet: line.trim(), |
| 168 | + }) |
| 169 | + break |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + // (2) console.* calls. |
| 174 | + CONSOLE_CALL_RE.lastIndex = 0 |
| 175 | + const consoleMatch = CONSOLE_CALL_RE.exec(line) |
| 176 | + if (consoleMatch) { |
| 177 | + violations.push({ |
| 178 | + column: consoleMatch.index + 1, |
| 179 | + file: relative, |
| 180 | + line: index + 1, |
| 181 | + reason: 'console-call', |
| 182 | + snippet: line.trim(), |
| 183 | + }) |
| 184 | + } |
| 185 | + |
| 186 | + // (3) Inline getDefaultLogger(). |
| 187 | + INLINE_LOGGER_RE.lastIndex = 0 |
| 188 | + const inlineMatch = INLINE_LOGGER_RE.exec(line) |
| 189 | + if (inlineMatch) { |
| 190 | + violations.push({ |
| 191 | + column: inlineMatch.index + 1, |
| 192 | + file: relative, |
| 193 | + line: index + 1, |
| 194 | + reason: 'inline-logger', |
| 195 | + snippet: line.trim(), |
| 196 | + }) |
| 197 | + } |
| 198 | + |
| 199 | + // (4) Dynamic import in non-bundled code. |
| 200 | + if (!inBundled) { |
| 201 | + DYNAMIC_IMPORT_RE.lastIndex = 0 |
| 202 | + const dynamicMatch = DYNAMIC_IMPORT_RE.exec(line) |
| 203 | + if (dynamicMatch) { |
| 204 | + violations.push({ |
| 205 | + column: dynamicMatch.index + 1, |
| 206 | + file: relative, |
| 207 | + line: index + 1, |
| 208 | + reason: 'dynamic-import', |
| 209 | + snippet: line.trim(), |
| 210 | + }) |
| 211 | + } |
| 212 | + } |
| 213 | + } |
| 214 | + } |
| 215 | + |
| 216 | + return { fileCount: matched.length, violations } |
| 217 | +} |
| 218 | + |
| 219 | +export const GUARDRAIL_FIX_HINTS: Readonly<Record<GuardrailReason, string>> = { |
| 220 | + 'console-call': |
| 221 | + 'Use logger from @socketsecurity/lib/logger: import { getDefaultLogger } from "@socketsecurity/lib/logger"; const logger = getDefaultLogger(); then logger.success(...) / logger.fail(...) / logger.warn(...) / logger.info(...) / logger.log(...).', |
| 222 | + 'dynamic-import': |
| 223 | + "Use a static `import` statement at the top of the file. Dynamic `import()` is only allowed inside bundled code (src/ or bundler configs); script files run directly via `node` and don't need lazy resolution.", |
| 224 | + 'inline-logger': |
| 225 | + 'Hoist the logger: `const logger = getDefaultLogger()` near the top of the file. Inline `getDefaultLogger().<method>()` re-resolves on every call.', |
| 226 | + 'status-emoji': |
| 227 | + 'Remove the literal symbol and use the matching logger method: ✓/✔/✅ → logger.success(...), ❌/✗ → logger.fail(...), ⚠/⚠️ → logger.warn(...), ℹ → logger.info(...).', |
| 228 | +} |
0 commit comments