Files
awesome-copilot/skills/cypress-flake-fixer/scripts/scan-cypress.mjs
T

507 lines
23 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Scan Cypress specs, support files, and config for flake-prone anti-patterns
// and for APIs removed in Cypress 12 through 16.
//
// Zero dependencies. Requires Node.js 18 or later.
// The scan is heuristic (text based, no AST). Treat findings as leads to
// confirm in the code, not as proof.
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const HELP = `Usage: node scan-cypress.mjs [paths...] [options]
Scans Cypress test code for flake-prone anti-patterns and removed APIs.
With no path, scans the current directory.
Options:
--json Print machine-readable JSON instead of text
--strict Also report low-confidence rules (CY1xx)
--rule <ids> Only report these rule ids, comma separated (CY001,CY005)
--max-per-rule <n> Findings listed per rule in text output (default 20, 0 = all)
--fail-on <level> Exit 1 if a finding at this level or above exists:
error | warn | info | never (default never)
--cypress-major <n> Override the Cypress major version detected from package.json
--list-rules Print the rule catalog and exit
-h, --help Show this help
Suppress a finding with a comment on the same or the previous line:
// cy-scan-ignore (all rules)
// cy-scan-ignore CY001 (one or more rule ids)
Exit codes: 0 ok, 1 threshold from --fail-on reached, 2 usage or I/O error.`
const RULES = {
CY001: ['fixed-wait', 'warn', 'cy.wait(<number>) waits on time. Wait on a cy.intercept() alias or a retrying assertion.'],
CY002: ['assigned-return-value', 'error', 'A cy command return value is assigned. Commands are queued and yield later; use .then(), .should(), or an alias.'],
CY003: ['async-await', 'error', 'async/await is used with Cypress commands. Commands are not promises; remove async and await.'],
CY004: ['force-true', 'warn', '{ force: true } skips actionability checks. Find what covers or disables the element.'],
CY005: ['chain-after-action', 'warn', 'A command is chained after an action. Safe only if the element cannot re-render; otherwise start a new cy.get().'],
CY006: ['one-shot-assertion', 'warn', 'expect() inside .then() runs once and does not retry. Use .should() or .should(callback).'],
CY007: ['removed-api', 'error', 'API removed or changed in a newer Cypress major.'],
CY008: ['conditional-on-dom', 'warn', 'Test branches on DOM state. Make the state deterministic, then assert it.'],
CY009: ['exclusive-or-debug', 'warn', '.only, cy.pause(), or cy.debug() left in the code.'],
CY010: ['cleanup-in-after-hook', 'info', 'State is cleaned in after/afterEach. Reset state in before/beforeEach so interrupted runs do not leak.'],
CY011: ['absolute-local-visit', 'info', 'cy.visit() uses an absolute local URL. Set baseUrl and visit a relative path.'],
CY012: ['ui-login-without-session', 'info', 'Password is typed through the UI in several files and cy.session() is never used.'],
CY013: ['positional-selector', 'info', 'Selector depends on element position. Prefer a data-* attribute.'],
CY101: ['brittle-selector', 'info', 'Selector relies on a CSS class or id. Prefer a data-* attribute.'],
CY102: ['negative-assertion-as-wait', 'info', 'A negative assertion passes before the element has rendered. Confirm a positive state was asserted first.'],
CY103: ['variable-wait', 'info', 'cy.wait() is called with an identifier. Confirm it is an alias, not a number of milliseconds.'],
}
const LEVELS = { info: 1, warn: 2, error: 3 }
const REMOVED = [
[/\bCypress\s*\.\s*env\s*\(/g, 16, 'Cypress.env() was removed in 16.0 (deprecated 15.10). Use cy.env([...]) for secrets or Cypress.expose() for public values.'],
[/\bcy\s*\.\s*exec\s*\(/g, 16, 'cy.exec() was removed in 16.0. Register a task in setupNodeEvents and call cy.task().'],
[/\)\s*\.\s*end\s*\(\s*\)/g, 16, '.end() was removed in 16.0. Delete the call.'],
[/\bexecTimeout\b/g, 16, 'execTimeout was removed in 16.0 with cy.exec(). Use taskTimeout.'],
[/\bexperimentalMemoryManagement\b/g, 16, 'experimentalMemoryManagement was removed in 16.0. Delete it, or set manageBrowserMemory: false to opt out.'],
[/\bexperimentalFastVisibility\b/g, 16, 'experimentalFastVisibility was removed in 16.0. visibilityStrategy defaults to "modern".'],
[/\bexperimentalSourceRewriting\b/g, 16, 'experimentalSourceRewriting was removed in 16.0. Delete it; use removeSRIAttributes for SRI errors.'],
[/\ballowCypressEnv\b/g, 16, 'allowCypressEnv was removed in 16.0. Delete it.'],
[/\bCypress\s*\.\s*config\s*\(\s*['"`](?:viewportWidth|viewportHeight|blockHosts)['"`]\s*,/g, 16, 'Setting this through Cypress.config() during a test throws in 16.0. Use cy.viewport() or the test config object.'],
[/\bCypress\s*\.\s*Commands\s*\.\s*overwrite\s*\(\s*['"`](?:getCookie|getCookies|getAllCookies|getAllLocalStorage|getAllSessionStorage)['"`]/g, 16, 'This command is a query in 16.0. Use Cypress.Commands.overwriteQuery().'],
[/\bCypress\s*\.\s*SelectorPlayground\b/g, 15, 'Cypress.SelectorPlayground was renamed Cypress.ElementSelector in 15.0.'],
[/\bexperimentalSkipDomainInjection\b/g, 14, 'experimentalSkipDomainInjection was removed in 14.0. Use cy.origin() for subdomains.'],
[/\bexperimentalJustInTimeCompile\b/g, 14, 'experimentalJustInTimeCompile became justInTimeCompile in 14.0.'],
[/\bvideoUploadOnPasses\b/g, 13, 'videoUploadOnPasses was removed in 13.0.'],
[/\bcy\s*\.\s*(?:server|route)\s*\(/g, 12, 'cy.server() and cy.route() were removed in 12.0. Use cy.intercept().'],
[/\bCypress\s*\.\s*Cookies\s*\.\s*(?:preserveOnce|defaults)\b/g, 12, 'Cypress.Cookies.preserveOnce() and .defaults() were removed in 12.0. Use cy.session().'],
[/\bexperimentalSessionAndOrigin\b/g, 12, 'experimentalSessionAndOrigin was removed in 12.0. cy.session() and cy.origin() are stable.'],
[/\bcy\s*\.\s*route2\s*\(/g, 7, 'cy.route2() was renamed cy.intercept() in 7.0.'],
[/\bcy\s*\.\s*xpath\s*\(/g, null, 'cy.xpath() comes from the deprecated cypress-xpath plugin. Use cy.get() or cy.contains().'],
]
const ACTIONS = 'click|dblclick|rightclick|type|clear|check|uncheck|select|selectFile|trigger|focus|blur|submit|scrollIntoView|scrollTo'
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'out', 'screenshots', 'videos', 'downloads'])
const CODE_EXT = /\.(?:[cm]?[jt]sx?)$/
function rel(file) {
const relative = path.relative(process.cwd(), file)
const shown = relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
return shown.split(path.sep).join('/')
}
function fail(message) {
process.stderr.write(`scan-cypress: ${message}\n`)
process.exit(2)
}
function parseArgs(argv) {
const opts = { paths: [], json: false, strict: false, rules: null, maxPerRule: 20, failOn: 'never', major: undefined }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
const next = () => {
if (i + 1 >= argv.length) fail(`${arg} needs a value`)
return argv[++i]
}
if (arg === '-h' || arg === '--help') { process.stdout.write(`${HELP}\n`); process.exit(0) }
else if (arg === '--list-rules') {
for (const [id, [name, level, text]] of Object.entries(RULES)) process.stdout.write(`${id} ${name.padEnd(28)} ${level.padEnd(5)} ${text}\n`)
process.exit(0)
}
else if (arg === '--json') opts.json = true
else if (arg === '--strict') opts.strict = true
else if (arg === '--rule') opts.rules = new Set(next().split(',').map((s) => s.trim().toUpperCase()).filter(Boolean))
else if (arg === '--max-per-rule') {
opts.maxPerRule = Number(next())
if (!Number.isInteger(opts.maxPerRule) || opts.maxPerRule < 0) fail('--max-per-rule needs a non-negative integer')
}
else if (arg === '--fail-on') {
opts.failOn = next()
if (!['error', 'warn', 'info', 'never'].includes(opts.failOn)) fail('--fail-on must be error, warn, info, or never')
}
else if (arg === '--cypress-major') {
opts.major = Number(next())
if (!Number.isInteger(opts.major) || opts.major < 1) fail('--cypress-major needs a positive integer')
}
else if (arg.startsWith('-')) fail(`unknown option ${arg} (try --help)`)
else opts.paths.push(arg)
}
if (opts.paths.length === 0) opts.paths.push('.')
if (opts.rules) for (const id of opts.rules) if (!RULES[id]) fail(`unknown rule ${id} (try --list-rules)`)
return opts
}
function isCypressFile(file) {
const normalized = rel(file)
const base = path.basename(normalized)
if (/^cypress\.config\.[cm]?[jt]s$/.test(base)) return true
if (!CODE_EXT.test(base)) return false
if (/\.cy\.[cm]?[jt]sx?$/.test(base)) return true
return /(?:^|\/)cypress\//.test(normalized)
}
function walk(target, files, coffee) {
let stat
try { stat = fs.statSync(target) } catch { fail(`cannot read ${target}`) }
if (stat.isFile()) {
if (isCypressFile(target) || CODE_EXT.test(target)) files.push(target)
return
}
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
const full = path.join(target, entry.name)
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) walk(full, files, coffee)
} else if (entry.isFile()) {
if (isCypressFile(full)) files.push(full)
else if (/\.coffee$/.test(entry.name) && /(?:^|\/)cypress\//.test(full.split(path.sep).join('/'))) coffee.push(full)
}
}
}
function detectCypress(startDir) {
let dir = path.resolve(startDir)
for (let depth = 0; depth < 8; depth++) {
const file = path.join(dir, 'package.json')
if (fs.existsSync(file)) {
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'))
const range = pkg.devDependencies?.cypress ?? pkg.dependencies?.cypress
if (range) {
const match = String(range).match(/\d+/)
return { range: String(range), major: match ? Number(match[0]) : null, source: file }
}
} catch { /* unreadable package.json: keep walking up */ }
}
const parent = path.dirname(dir)
if (parent === dir) break
dir = parent
}
return { range: null, major: null, source: null }
}
// Replace comments with spaces so offsets and line numbers stay valid.
function blankComments(src) {
let out = ''
let state = 'code'
for (let i = 0; i < src.length;) {
const c = src[i]
const d = src[i + 1]
if (state === 'code') {
if (c === '\\') { out += c + (d ?? ''); i += 2; continue }
if (c === '/' && d === '/') { state = 'line'; out += ' '; i += 2; continue }
if (c === '/' && d === '*') { state = 'block'; out += ' '; i += 2; continue }
if (c === "'") state = 'sq'
else if (c === '"') state = 'dq'
else if (c === '`') state = 'tpl'
out += c; i++; continue
}
if (state === 'line') {
if (c === '\n') { state = 'code'; out += c } else out += ' '
i++; continue
}
if (state === 'block') {
if (c === '*' && d === '/') { state = 'code'; out += ' '; i += 2; continue }
out += c === '\n' ? '\n' : ' '
i++; continue
}
if (c === '\\') { out += c + (d ?? ''); i += 2; continue }
if ((state === 'sq' && c === "'") || (state === 'dq' && c === '"') || (state === 'tpl' && c === '`')) state = 'code'
else if ((state === 'sq' || state === 'dq') && c === '\n') state = 'code'
out += c; i++
}
return out
}
// Index of the parenthesis that closes the one at `open`, or -1.
function matchParen(src, open) {
let depth = 0
let quote = null
for (let i = open; i < src.length; i++) {
const c = src[i]
if (quote) {
if (c === '\\') { i++; continue }
if (c === quote) quote = null
continue
}
if (c === "'" || c === '"' || c === '`') { quote = c; continue }
if (c === '(') depth++
else if (c === ')') { depth--; if (depth === 0) return i }
}
return -1
}
// Start offset of the statement whose chain contains offset `idx`.
function chainStart(src, idx) {
let depth = 0
for (let j = idx - 1; j >= 0; j--) {
const c = src[j]
if (c === ')' || c === ']' || c === '}') depth++
else if (c === '(' || c === '[' || c === '{') {
if (depth === 0) return j + 1
depth--
} else if (depth === 0) {
if (c === ';') return j + 1
if (c === '\n') {
const rest = src.slice(j + 1, idx).trimStart()
if (rest && rest[0] !== '.') return j + 1
}
}
}
return 0
}
function countTopLevelCommas(args) {
let depth = 0
let quote = null
let commas = 0
for (let i = 0; i < args.length; i++) {
const c = args[i]
if (quote) {
if (c === '\\') { i++; continue }
if (c === quote) quote = null
continue
}
if (c === "'" || c === '"' || c === '`') quote = c
else if (c === '(' || c === '[' || c === '{') depth++
else if (c === ')' || c === ']' || c === '}') depth--
else if (c === ',' && depth === 0 && args.slice(i + 1).trim()) commas++
}
return commas
}
function scanFile(file, opts, major, findings) {
const raw = fs.readFileSync(file, 'utf8')
const rawLines = raw.split('\n')
const src = blankComments(raw)
const lineStarts = [0]
for (let i = 0; i < src.length; i++) if (src[i] === '\n') lineStarts.push(i + 1)
const lineOf = (offset) => {
let lo = 0
let hi = lineStarts.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (lineStarts[mid] <= offset) lo = mid
else hi = mid - 1
}
return lo + 1
}
const suppressed = (line, rule) => {
for (const text of [rawLines[line - 1], rawLines[line - 2]]) {
const match = text?.match(/cy-scan-ignore\b([^\n]*)/)
if (!match) continue
const ids = match[1].match(/CY\d{3}/g)
if (!ids || ids.includes(rule)) return true
}
return false
}
const add = (rule, offset, message, level) => {
if (opts.rules && !opts.rules.has(rule)) return
if (!opts.strict && /^CY1\d\d$/.test(rule) && !(opts.rules && opts.rules.has(rule))) return
const line = lineOf(offset)
if (suppressed(line, rule)) return
const snippet = (rawLines[line - 1] ?? '').trim()
findings.push({
rule,
name: RULES[rule][0],
level: level ?? RULES[rule][1],
file: rel(file),
line,
message: message ?? RULES[rule][2],
snippet: snippet.length > 140 ? `${snippet.slice(0, 137)}...` : snippet,
})
}
const each = (regex, fn) => {
regex.lastIndex = 0
let match
while ((match = regex.exec(src)) !== null) {
fn(match)
if (match[0].length === 0) regex.lastIndex++
}
}
// CY001 fixed waits
each(/(?:\bcy|\))\s*\.\s*wait\s*\(\s*\d[\d_.]*\s*[,)]/g, (m) => add('CY001', m.index + m[0].indexOf('wait')))
// CY103 wait on an identifier (strict)
each(/\bcy\s*\.\s*wait\s*\(\s*[A-Za-z_$][\w$.]*\s*\)/g, (m) => add('CY103', m.index))
// CY002 assigned return values. cy.stub() and cy.spy() are synchronous and exempt.
each(/\b(?:const|let|var)\s+[^=;\n]+=\s*cy\s*\.\s*(\w+)/g, (m) => {
if (!['stub', 'spy', 'state'].includes(m[1])) add('CY002', m.index)
})
// CY003 async tests and awaited commands
each(/\b(?:it|specify|before|beforeEach|after|afterEach)(?:\s*\.\s*(?:only|skip))?\s*\(\s*(?:(['"`])(?:\\.|(?!\1)[^\\])*\1\s*,\s*)?(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}\s*,\s*)?async\b/g, (m) => add('CY003', m.index))
each(/\bawait\s+cy\s*\./g, (m) => add('CY003', m.index, 'A cy command is awaited. Commands are not promises; use .then().'))
// CY004 force
each(/\bforce\s*:\s*true\b/g, (m) => add('CY004', m.index))
// CY005 chaining after an action
each(new RegExp(`\\.\\s*(${ACTIONS})\\s*\\(`, 'g'), (m) => {
const open = m.index + m[0].length - 1
const close = matchParen(src, open)
if (close === -1) return
const tail = src.slice(close + 1).match(/^\s*\.\s*([A-Za-z_$][\w$]*)/)
if (!tail) return
const next = tail[1]
if (['then', 'as', 'catch', 'finally', 'end'].includes(next)) return
const start = chainStart(src, m.index)
if (!/^\s*(?:return\s+)?cy\b/.test(src.slice(start, m.index))) return
const soft = next === 'should' || next === 'and'
add(
'CY005',
m.index,
soft
? `.${next}() is chained after .${m[1]}(). If the action re-renders the element the subject is stale; re-query before asserting.`
: `.${next}() is chained after .${m[1]}(). Start a new cy.get() unless the element cannot re-render.`,
soft ? 'info' : 'warn',
)
})
// CY006 expect() inside .then() after a DOM or page query
each(/\.\s*then\s*\(/g, (m) => {
const open = m.index + m[0].length - 1
const close = matchParen(src, open)
if (close === -1) return
const body = src.slice(open + 1, close)
if (!/\bexpect\s*\(|\bassert\s*[.(]/.test(body) || /\bcy\s*\./.test(body)) return
const chain = src.slice(chainStart(src, m.index), m.index)
if (!/^\s*(?:return\s+)?cy\b/.test(chain)) return
if (/\bcy\s*\.\s*(?:request|task|wait|env|fixture|readFile|wrap|window|exec|origin|session)\s*\(/.test(chain)) return
if (/\bcy\s*\.\s*get\s*\(\s*['"`]@/.test(chain)) return
if (!/\bcy\s*\.\s*(?:get|contains|url|title|location|hash|focused|getCookie|getCookies|getAllCookies|getAllLocalStorage|getAllSessionStorage)\s*\(/.test(chain)) return
add('CY006', m.index)
})
// CY007 removed APIs
for (const [regex, removedIn, text] of REMOVED) {
each(regex, (m) => {
const broken = removedIn === null ? false : major === null ? null : major >= removedIn
const level = broken === true ? 'error' : 'warn'
const suffix = removedIn === null || major === null ? '' : broken ? ` Installed major is ${major}: this already fails.` : ` Installed major is ${major}: fix before upgrading.`
add('CY007', m.index, `${text}${suffix}`, level)
})
}
each(/\bcy\s*\.\s*stub\s*\(/g, (m) => {
const open = m.index + m[0].length - 1
const close = matchParen(src, open)
if (close === -1) return
if (countTopLevelCommas(src.slice(open + 1, close)) >= 2) {
const broken = major !== null && major >= 15
add('CY007', m.index, 'cy.stub(obj, name, fn) was removed in 15.0. Use cy.stub(obj, name).callsFake(fn).', broken ? 'error' : 'warn')
}
})
// CY008 conditional testing
each(/\bif\s*\(\s*!?\s*\$\w+\s*\.\s*(?:find|is|hasClass|children|text|length)\b/g, (m) => add('CY008', m.index))
// CY009 exclusive tests and debug commands
each(/\b(?:it|describe|context|specify)\s*\.\s*only\s*\(/g, (m) => add('CY009', m.index, '.only is committed: the rest of the suite does not run.'))
each(/(?:\bcy|\))\s*\.\s*(?:pause|debug)\s*\(\s*\)/g, (m) => add('CY009', m.index, 'cy.pause() or cy.debug() left in the code.'))
// CY010 cleanup hooks
each(/(?:^|\n)[ \t]*(?:after|afterEach)\s*\(/g, (m) => add('CY010', m.index + m[0].search(/a/)))
// CY011 absolute local visit
each(/\bcy\s*\.\s*visit\s*\(\s*['"`]https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)/g, (m) => add('CY011', m.index))
// CY013 and CY101 selectors
each(/(?:\bcy\s*\.\s*get|\.\s*find)\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (m) => {
const selector = m[2]
if (selector.startsWith('@') || selector.includes('${')) return
if (/:nth-(?:child|of-type|last-child)\(|:eq\(|:first\b|:last\b/.test(selector)) add('CY013', m.index)
else if (!/\[data-|\[aria-|\[role=|\[name=/.test(selector) && /(?:^|[\s>+~])[.#][\w-]+/.test(selector)) add('CY101', m.index)
})
// CY102 negative assertions
each(/\.\s*should\s*\(\s*['"`]not\.(?:exist|be\.visible)['"`]\s*\)/g, (m) => add('CY102', m.index))
return {
typesPassword: /(?:password|passwd|pwd)[^\n]{0,80}\.\s*type\s*\(|type\s*=\s*["']?password/i.test(src),
usesSession: /\bcy\s*\.\s*session\s*\(/.test(src),
}
}
function main() {
const opts = parseArgs(process.argv.slice(2))
const files = []
const coffee = []
for (const target of opts.paths) walk(target, files, coffee)
const unique = [...new Set(files)].sort()
const firstPath = opts.paths[0]
const startDir = fs.statSync(firstPath).isDirectory() ? firstPath : path.dirname(firstPath)
const detected = detectCypress(startDir)
const major = opts.major ?? detected.major
const findings = []
const passwordFiles = []
let sessionUsed = false
for (const file of unique) {
const facts = scanFile(file, opts, major, findings)
if (facts.typesPassword) passwordFiles.push(rel(file))
if (facts.usesSession) sessionUsed = true
}
const wants = (rule) => !opts.rules || opts.rules.has(rule)
if (wants('CY012') && !sessionUsed && passwordFiles.length >= 2) {
findings.push({
rule: 'CY012', name: RULES.CY012[0], level: RULES.CY012[1], file: passwordFiles[0], line: 1,
message: `${RULES.CY012[2]} Files: ${passwordFiles.slice(0, 8).join(', ')}${passwordFiles.length > 8 ? ', ...' : ''}`,
snippet: '',
})
}
if (wants('CY007')) {
for (const file of coffee) {
findings.push({
rule: 'CY007', name: RULES.CY007[0], level: major !== null && major >= 16 ? 'error' : 'warn',
file: rel(file), line: 1,
message: 'Built-in CoffeeScript support was removed in 16.0. Convert the file to JavaScript or TypeScript.', snippet: '',
})
}
}
findings.sort((a, b) => a.rule.localeCompare(b.rule) || a.file.localeCompare(b.file) || a.line - b.line)
const summary = {}
for (const f of findings) summary[f.rule] = (summary[f.rule] ?? 0) + 1
if (opts.json) {
process.stdout.write(`${JSON.stringify({
cypress: { range: detected.range, major, source: detected.source },
filesScanned: unique.length,
strict: opts.strict,
summary,
findings,
}, null, 2)}\n`)
} else {
const lines = ['Cypress anti-pattern scan', '']
lines.push(major === null
? 'Cypress version: not detected (pass --cypress-major <n> to grade removed APIs)'
: `Cypress version: major ${major}${detected.range && opts.major === undefined ? ` (range ${detected.range} in ${detected.source})` : ' (from --cypress-major)'}`)
lines.push(`Files scanned: ${unique.length}`)
lines.push(`Findings: ${findings.length}`, '')
if (unique.length === 0) lines.push('No Cypress files found. Pass the folder that contains cypress/ or *.cy.* files.')
for (const rule of Object.keys(summary)) {
const group = findings.filter((f) => f.rule === rule)
lines.push(`${rule} ${RULES[rule][0]} (${group.length})`)
lines.push(` ${RULES[rule][2]}`)
const shown = opts.maxPerRule === 0 ? group : group.slice(0, opts.maxPerRule)
for (const f of shown) {
lines.push(` [${f.level}] ${f.file}:${f.line} ${f.snippet}`)
if (f.message !== RULES[rule][2]) lines.push(` ${f.message}`)
}
if (shown.length < group.length) lines.push(` ... ${group.length - shown.length} more (use --max-per-rule 0 or --json)`)
lines.push('')
}
if (!opts.strict && findings.length > 0) lines.push('Run with --strict to include low-confidence rules (CY1xx).')
process.stdout.write(`${lines.join('\n')}\n`)
}
if (opts.failOn !== 'never') {
const threshold = LEVELS[opts.failOn]
if (findings.some((f) => LEVELS[f.level] >= threshold)) process.exit(1)
}
}
main()