Created
July 29, 2026 11:03
-
-
Save kanzure/436cf9c66fa43e0576a0a60aa14b2fba to your computer and use it in GitHub Desktop.
slop-text.mjs - find excessive text in your UI project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * slop-text — a census of long user-facing text in a React codebase. | |
| * | |
| * Long copy in a UI is usually a symptom rather than a feature: a helper | |
| * paragraph under a field is explaining a control that should have explained | |
| * itself, and a two-sentence tooltip is a design decision that was deferred | |
| * into prose. This walks the JSX/TSX sources, reconstructs every string that | |
| * reaches a user, and ranks the ones that are long *for their role* — a | |
| * 12-word button label is worse than a 12-word paragraph. | |
| * | |
| * Why AST and not jsdom/React: rendering only reaches the branches a given set | |
| * of props and API data happens to produce, so empty states, error states and | |
| * anything behind a feature flag stay invisible; it also needs a server, a | |
| * session, and seconds per page. Parsing sees every branch at once and the | |
| * whole tree runs in about a second, which is what makes it cheap enough to | |
| * run like a linter. | |
| * | |
| * Standalone and project-agnostic: one dependency (@babel/parser), no config | |
| * file, no knowledge of any particular codebase. Anything site-specific — which | |
| * generated trees to skip, which roots to scan — is passed in on the command | |
| * line, so this file can be lifted into another repo unchanged. MIT. | |
| */ | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import { fileURLToPath } from 'node:url'; | |
| import babelParser from '@babel/parser'; | |
| const { parse } = babelParser; | |
| const SOURCE_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts', '.mjs']); | |
| /** Generated, vendored, or non-shipping trees, in any JS project. */ | |
| const EXCLUDED_DIRECTORIES = new Set([ | |
| 'node_modules', | |
| 'dist', | |
| 'build', | |
| 'coverage', | |
| '.git', | |
| '.next', | |
| 'storybook-static', | |
| 'playwright-report', | |
| 'test-results', | |
| ]); | |
| /** | |
| * Path fragments skipped everywhere. Generated code and per-project trees are | |
| * *not* listed here — pass those with `--exclude` so this file stays portable. | |
| */ | |
| const EXCLUDED_PATH_FRAGMENTS = [`${path.sep}__mocks__${path.sep}`, `${path.sep}__fixtures__${path.sep}`]; | |
| const isTestFile = (filePath) => /\.(test|spec)\.[jt]sx?$/.test(filePath) || /\.d\.ts$/.test(filePath); | |
| /** | |
| * Roles, and the point at which text in each stops being a label and starts | |
| * being an essay. The numbers are deliberately per-role: the same 15 words are | |
| * unremarkable in a paragraph and a design failure on a button. | |
| * | |
| * `sentences` is the second trigger — anything that needs a full stop and then | |
| * keeps going is explaining rather than naming, whatever its word count. | |
| */ | |
| const ROLES = { | |
| action: { words: 6, sentences: 2, blurb: 'button / link' }, | |
| heading: { words: 10, sentences: 2, blurb: 'heading' }, | |
| label: { words: 8, sentences: 2, blurb: 'field label' }, | |
| placeholder: { words: 7, sentences: 2, blurb: 'input placeholder' }, | |
| tooltip: { words: 12, sentences: 2, blurb: 'title / aria-label' }, | |
| hint: { words: 14, sentences: 2, blurb: 'helper text' }, | |
| body: { words: 40, sentences: 4, blurb: 'body copy' }, | |
| }; | |
| /** | |
| * Attributes that render as text. Anything not listed here is treated as | |
| * plumbing (className, href, testids) even when its value is a long string. | |
| */ | |
| const ATTRIBUTE_ROLES = new Map([ | |
| ['placeholder', 'placeholder'], | |
| ['title', 'tooltip'], | |
| ['aria-label', 'tooltip'], | |
| ['aria-description', 'tooltip'], | |
| ['alt', 'tooltip'], | |
| ['tooltip', 'tooltip'], | |
| ['label', 'label'], | |
| ['ariaLabel', 'tooltip'], | |
| ['legend', 'label'], | |
| ['heading', 'heading'], | |
| ['description', 'hint'], | |
| ['helpText', 'hint'], | |
| ['helperText', 'hint'], | |
| ['hint', 'hint'], | |
| ['subtitle', 'hint'], | |
| ['caption', 'hint'], | |
| ['note', 'hint'], | |
| ['blurb', 'hint'], | |
| ['message', 'body'], | |
| ['emptyMessage', 'body'], | |
| ['emptyState', 'body'], | |
| ['body', 'body'], | |
| ['confirmLabel', 'action'], | |
| ['cancelLabel', 'action'], | |
| ['ctaLabel', 'action'], | |
| ['buttonLabel', 'action'], | |
| ['submitLabel', 'action'], | |
| ['actionLabel', 'action'], | |
| ]); | |
| /** | |
| * Object keys that carry copy. Catches the message maps and config objects | |
| * that hold text far away from the component that renders it. | |
| */ | |
| const OBJECT_KEY_ROLES = new Map([ | |
| ['title', 'heading'], | |
| ['heading', 'heading'], | |
| ['label', 'label'], | |
| ['placeholder', 'placeholder'], | |
| ['tooltip', 'tooltip'], | |
| ['description', 'hint'], | |
| ['helpText', 'hint'], | |
| ['helperText', 'hint'], | |
| ['hint', 'hint'], | |
| ['subtitle', 'hint'], | |
| ['caption', 'hint'], | |
| ['note', 'hint'], | |
| ['blurb', 'hint'], | |
| ['summary', 'body'], | |
| ['message', 'body'], | |
| ['body', 'body'], | |
| ['text', 'body'], | |
| ['copy', 'body'], | |
| ['cta', 'action'], | |
| ]); | |
| const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); | |
| const ACTION_TAGS = new Set(['button', 'a', 'Link', 'NavLink']); | |
| const LABEL_TAGS = new Set(['label', 'legend', 'th', 'caption', 'summary', 'figcaption']); | |
| /** Form controls, matched by shape so a project's own wrappers count too. */ | |
| const CONTROL_TAG_PATTERN = /^(input|select|textarea)$|(Input|Select|Textarea|TextArea|Field|Picker|Combobox|SearchBox)$/; | |
| /** Placeholder standing in for a `{value}` interpolation, counted as one word. */ | |
| const DYNAMIC = '{…}'; | |
| // --------------------------------------------------------------------------- | |
| // Text measurement | |
| // --------------------------------------------------------------------------- | |
| const WORD_PATTERN = /[\p{L}\p{N}][\p{L}\p{N}'’./-]*|\{…\}/gu; | |
| /** Abbreviations whose full stop is not the end of a sentence. */ | |
| const NON_TERMINAL = /\b(?:e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|approx|no)\.$/i; | |
| const countWords = (text) => (text.match(WORD_PATTERN) || []).length; | |
| const countSentences = (text) => { | |
| if (!countWords(text)) return 0; | |
| let sentences = 0; | |
| // A terminator followed by whitespace or end-of-string, minus the | |
| // abbreviations that only look like one. | |
| const pattern = /[.!?]+(?=\s|$)/g; | |
| let match; | |
| while ((match = pattern.exec(text)) !== null) { | |
| const upTo = text.slice(0, match.index + match[0].length); | |
| if (!NON_TERMINAL.test(upTo)) sentences += 1; | |
| } | |
| // Text with words but no terminator is still one thing being said. | |
| return Math.max(sentences, 1); | |
| }; | |
| const normalize = (text) => text.replace(/\s+/g, ' ').trim(); | |
| /** | |
| * True when a string looks like copy rather than like machinery. Filters out | |
| * the class-name soup, URLs, keys, and single symbols that share a type with | |
| * real text. | |
| */ | |
| const looksLikeCopy = (text) => { | |
| const stripped = text.replace(/\{…\}/g, '').trim(); | |
| if (!stripped) return false; | |
| if (!/\p{L}{2}/u.test(stripped)) return false; | |
| if (/^(https?:\/\/|\/|#|mailto:)/.test(stripped)) return false; | |
| if (/^[a-z0-9_.-]+$/i.test(stripped) && !/\s/.test(stripped)) return false; | |
| return true; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // AST walking | |
| // --------------------------------------------------------------------------- | |
| const SKIP_NODE_KEYS = new Set(['loc', 'range', 'leadingComments', 'trailingComments', 'innerComments', 'extra', 'tokens', 'comments']); | |
| const isNode = (value) => value && typeof value === 'object' && typeof value.type === 'string'; | |
| /** Depth-first walk over every node in the tree, in source order. */ | |
| function walk(node, visit) { | |
| if (!isNode(node)) return; | |
| visit(node); | |
| for (const key of Object.keys(node)) { | |
| if (SKIP_NODE_KEYS.has(key)) continue; | |
| const value = node[key]; | |
| if (Array.isArray(value)) { | |
| for (const child of value) walk(child, visit); | |
| } else if (isNode(value)) { | |
| walk(value, visit); | |
| } | |
| } | |
| } | |
| const jsxName = (node) => { | |
| if (!node) return ''; | |
| if (node.type === 'JSXIdentifier') return node.name; | |
| if (node.type === 'JSXNamespacedName') return `${node.namespace.name}:${node.name.name}`; | |
| if (node.type === 'JSXMemberExpression') return `${jsxName(node.object)}.${jsxName(node.property)}`; | |
| return ''; | |
| }; | |
| /** The literal text of a template with no substitutions, else null. */ | |
| const staticString = (node) => { | |
| if (!node) return null; | |
| if (node.type === 'StringLiteral') return node.value; | |
| if (node.type === 'TemplateLiteral') { | |
| return node.quasis.map((quasi, index) => quasi.value.cooked + (index < node.expressions.length ? DYNAMIC : '')).join(''); | |
| } | |
| // Long copy is routinely spelled as 'one line ' + 'and the next' to stay | |
| // inside the line length, and it is one sentence to whoever reads it. | |
| if (node.type === 'BinaryExpression' && node.operator === '+') { | |
| const left = staticString(node.left); | |
| const right = staticString(node.right); | |
| if (left === null && right === null) return null; | |
| return (left ?? DYNAMIC) + (right ?? DYNAMIC); | |
| } | |
| return null; | |
| }; | |
| /** | |
| * Reconstruct what an element renders as text, following the same rules the | |
| * JSX runtime does: adjacent text and expressions concatenate, whitespace | |
| * collapses, and nested elements contribute their own text inline (a `<strong>` | |
| * in the middle of a sentence is part of that sentence). | |
| * | |
| * Returns the flattened text plus the elements found inside it, so the caller | |
| * can report the tightest container rather than every ancestor of a paragraph. | |
| */ | |
| function renderJsxText(node, nested) { | |
| const parts = []; | |
| const fromExpression = (expression) => { | |
| if (!expression || expression.type === 'JSXEmptyExpression') return; | |
| switch (expression.type) { | |
| case 'StringLiteral': | |
| case 'TemplateLiteral': | |
| parts.push(staticString(expression)); | |
| return; | |
| case 'JSXElement': | |
| case 'JSXFragment': | |
| nested.push(expression); | |
| parts.push(renderJsxText(expression, nested).text); | |
| return; | |
| case 'LogicalExpression': | |
| fromExpression(expression.right); | |
| return; | |
| case 'ConditionalExpression': | |
| // Both branches are text a user can see; keep the longer one so | |
| // one finding is not split into two half-sentences. | |
| { | |
| const left = collectBranch(expression.consequent, nested); | |
| const right = collectBranch(expression.alternate, nested); | |
| parts.push(left.length >= right.length ? left : right); | |
| } | |
| return; | |
| default: | |
| parts.push(DYNAMIC); | |
| } | |
| }; | |
| for (const child of node.children || []) { | |
| if (child.type === 'JSXText') { | |
| parts.push(child.value); | |
| } else if (child.type === 'JSXExpressionContainer') { | |
| fromExpression(child.expression); | |
| } else if (child.type === 'JSXElement' || child.type === 'JSXFragment') { | |
| nested.push(child); | |
| parts.push(renderJsxText(child, nested).text); | |
| } | |
| } | |
| // Joined with nothing, because JSXText carries its own spacing: the comma in | |
| // `{name}, then` belongs to the text node and must not be pushed off the word. | |
| return { text: normalize(parts.join('')), nested }; | |
| } | |
| function collectBranch(expression, nested) { | |
| const scratch = []; | |
| const holder = { children: [{ type: 'JSXExpressionContainer', expression }] }; | |
| const rendered = renderJsxText(holder, scratch); | |
| nested.push(...scratch); | |
| return rendered.text; | |
| } | |
| const attributeValue = (attribute) => { | |
| const { value } = attribute; | |
| if (!value) return null; | |
| if (value.type === 'StringLiteral') return value.value; | |
| if (value.type === 'JSXExpressionContainer') return staticString(value.expression); | |
| return null; | |
| }; | |
| const classNameOf = (element) => { | |
| const attributes = element.openingElement?.attributes || []; | |
| for (const attribute of attributes) { | |
| if (attribute.type !== 'JSXAttribute') continue; | |
| if (jsxName(attribute.name) !== 'className') continue; | |
| return attributeValue(attribute) || ''; | |
| } | |
| return ''; | |
| }; | |
| /** | |
| * Text that follows a form control inside the same parent is helper text, and | |
| * is judged as such however it is styled. This catches the copy that is | |
| * apologising for the control above it in the many places that do not reach for | |
| * the `text-xs` idiom. | |
| */ | |
| function markHelperSiblings(parent, helpers) { | |
| let seenControl = false; | |
| for (const child of parent.children || []) { | |
| if (child.type !== 'JSXElement') continue; | |
| const tag = jsxName(child.openingElement.name); | |
| if (CONTROL_TAG_PATTERN.test(tag)) { | |
| seenControl = true; | |
| } else if (seenControl) { | |
| helpers.add(child); | |
| } | |
| } | |
| } | |
| /** Which of the ROLES this element's text is playing, from its tag and classes. */ | |
| function classifyElement(element, helpers) { | |
| const tag = element.type === 'JSXFragment' ? '' : jsxName(element.openingElement.name); | |
| const className = classNameOf(element); | |
| if (HEADING_TAGS.has(tag)) return 'heading'; | |
| if (ACTION_TAGS.has(tag) || /\bbtn-/.test(className)) return 'action'; | |
| if (LABEL_TAGS.has(tag)) return 'label'; | |
| if (/\btext-(2xl|3xl|4xl|5xl)\b/.test(className)) return 'heading'; | |
| // Small muted type under a control is helper text — the single most common | |
| // home for copy that is apologising for the control above it. | |
| if (/\btext-xs\b/.test(className)) return 'hint'; | |
| if (/\btext-sm\b/.test(className) && /(muted|secondary|text-gray-[45]00)/.test(className)) return 'hint'; | |
| if (helpers.has(element)) return 'hint'; | |
| return 'body'; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Scanning | |
| // --------------------------------------------------------------------------- | |
| function collectFiles(target, out, excludes = []) { | |
| const fragments = [...EXCLUDED_PATH_FRAGMENTS, ...excludes.map((exclude) => exclude.split('/').join(path.sep))]; | |
| const excluded = (filePath) => fragments.some((fragment) => filePath.includes(fragment)); | |
| const stat = fs.statSync(target); | |
| if (stat.isFile()) { | |
| if (SOURCE_EXTENSIONS.has(path.extname(target)) && !isTestFile(target)) out.push(target); | |
| return; | |
| } | |
| for (const entry of fs.readdirSync(target, { withFileTypes: true })) { | |
| const filePath = path.join(target, entry.name); | |
| if (entry.isDirectory()) { | |
| if (EXCLUDED_DIRECTORIES.has(entry.name)) continue; | |
| if (excluded(filePath)) continue; | |
| collectFiles(filePath, out, excludes); | |
| } else if (entry.isFile()) { | |
| if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) continue; | |
| if (isTestFile(filePath)) continue; | |
| if (excluded(filePath)) continue; | |
| out.push(filePath); | |
| } | |
| } | |
| } | |
| const parseFile = (source, filePath) => | |
| parse(source, { | |
| sourceType: 'module', | |
| allowReturnOutsideFunction: true, | |
| errorRecovery: true, | |
| plugins: [ | |
| 'jsx', | |
| path.extname(filePath) === '.ts' || path.extname(filePath) === '.tsx' ? 'typescript' : 'flow', | |
| 'decorators-legacy', | |
| 'classProperties', | |
| 'topLevelAwait', | |
| 'importAssertions', | |
| ], | |
| }); | |
| /** Lines carrying a `slop-text-ignore` comment; findings on the next line are muted. */ | |
| function ignoredLines(ast) { | |
| const lines = new Set(); | |
| for (const comment of ast.comments || []) { | |
| if (!comment.value.includes('slop-text-ignore')) continue; | |
| lines.add(comment.loc.end.line); | |
| lines.add(comment.loc.end.line + 1); | |
| } | |
| return lines; | |
| } | |
| function scanFile(filePath) { | |
| const source = fs.readFileSync(filePath, 'utf8'); | |
| if (!source.includes('<') && !/['"`]/.test(source)) return []; | |
| let ast; | |
| try { | |
| ast = parseFile(source, filePath); | |
| } catch (error) { | |
| process.stderr.write(`slop-text: could not parse ${path.relative(process.cwd(), filePath)}: ${error.message}\n`); | |
| return []; | |
| } | |
| const findings = []; | |
| const muted = ignoredLines(ast); | |
| // Elements that are an ancestor of some other text-bearing element: the | |
| // paragraph is the finding, not the six divs wrapped around it. | |
| const hasTextChild = new Set(); | |
| // Elements sitting after a form control in their parent (see markHelperSiblings). | |
| const helpers = new Set(); | |
| const add = (role, text, loc, context) => { | |
| const normalized = normalize(text); | |
| if (!looksLikeCopy(normalized)) return; | |
| const words = countWords(normalized); | |
| const sentences = countSentences(normalized); | |
| const limits = ROLES[role]; | |
| if (words <= limits.words && sentences <= limits.sentences) return; | |
| if (muted.has(loc.start.line)) return; | |
| findings.push({ | |
| file: path.relative(process.cwd(), filePath), | |
| line: loc.start.line, | |
| role, | |
| context, | |
| words, | |
| sentences, | |
| limit: limits.words, | |
| // How far past its role's budget this is; the ranking that puts a | |
| // 20-word button above a 45-word paragraph. | |
| ratio: Number((words / limits.words).toFixed(2)), | |
| text: normalized, | |
| }); | |
| }; | |
| walk(ast, (node) => { | |
| if (node.type === 'JSXElement' || node.type === 'JSXFragment') { | |
| // Parents are visited before their children, so the helper-sibling | |
| // marks are in place by the time a child is classified. | |
| markHelperSiblings(node, helpers); | |
| const nested = []; | |
| const { text } = renderJsxText(node, nested); | |
| if (normalize(text)) { | |
| for (const child of nested) { | |
| const childText = renderJsxText(child, []).text; | |
| if (countWords(childText) >= 3) hasTextChild.add(node); | |
| } | |
| } | |
| if (!hasTextChild.has(node) && text) { | |
| const tag = node.type === 'JSXFragment' ? 'fragment' : jsxName(node.openingElement.name); | |
| add(classifyElement(node, helpers), text, node.loc, `<${tag}>`); | |
| } | |
| } | |
| if (node.type === 'JSXAttribute') { | |
| const name = jsxName(node.name); | |
| const role = ATTRIBUTE_ROLES.get(name); | |
| if (role) { | |
| const value = attributeValue(node); | |
| if (value) add(role, value, node.loc, name); | |
| } | |
| } | |
| if (node.type === 'ObjectProperty' && !node.computed) { | |
| const key = node.key.type === 'Identifier' ? node.key.name : node.key.type === 'StringLiteral' ? node.key.value : null; | |
| const role = key && OBJECT_KEY_ROLES.get(key); | |
| if (role) { | |
| const value = staticString(node.value); | |
| if (value) add(role, value, node.loc, `${key}:`); | |
| } | |
| } | |
| }); | |
| // An outer element only learns it has a text-bearing child after the walk | |
| // reaches that child, so drop the ancestors here rather than during it. | |
| return findings.filter((finding, index) => { | |
| if (!finding.context.startsWith('<')) return true; | |
| return !findings.some( | |
| (other, otherIndex) => | |
| otherIndex !== index && | |
| other.context.startsWith('<') && | |
| other.line >= finding.line && | |
| other.text !== finding.text && | |
| finding.text.includes(other.text), | |
| ); | |
| }); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CLI | |
| // --------------------------------------------------------------------------- | |
| const USAGE = `Usage: slop-text [options] [paths...] | |
| Ranks long user-facing text in JSX/TSX sources. Defaults to ./src. | |
| --limit N show N findings (default 40, 0 for all) | |
| --min-words N only report text of at least N words, whatever its role | |
| --role NAME only this role (${Object.keys(ROLES).join(', ')}) | |
| --sort KEY ratio (default), words, sentences, file | |
| --exclude PATH skip paths containing this fragment (repeatable) | |
| --max N exit 1 when more than N findings survive the filters | |
| --json machine-readable output | |
| --full print the whole string instead of a 160-char excerpt | |
| --help | |
| `; | |
| function parseArguments(argv) { | |
| const options = { limit: 40, minWords: 0, role: null, sort: 'ratio', max: null, json: false, full: false, exclude: [], paths: [] }; | |
| for (let index = 0; index < argv.length; index += 1) { | |
| const argument = argv[index]; | |
| const next = () => argv[(index += 1)]; | |
| switch (argument) { | |
| case '--help': | |
| case '-h': | |
| process.stdout.write(USAGE); | |
| process.exit(0); | |
| break; | |
| case '--limit': | |
| options.limit = Number(next()); | |
| break; | |
| case '--min-words': | |
| options.minWords = Number(next()); | |
| break; | |
| case '--role': | |
| options.role = next(); | |
| break; | |
| case '--sort': | |
| options.sort = next(); | |
| break; | |
| case '--exclude': | |
| options.exclude.push(next()); | |
| break; | |
| case '--max': | |
| options.max = Number(next()); | |
| break; | |
| case '--json': | |
| options.json = true; | |
| break; | |
| case '--full': | |
| options.full = true; | |
| break; | |
| default: | |
| if (argument.startsWith('-')) { | |
| process.stderr.write(`slop-text: unknown option ${argument}\n\n${USAGE}`); | |
| process.exit(2); | |
| } | |
| options.paths.push(argument); | |
| } | |
| } | |
| if (options.role && !ROLES[options.role]) { | |
| process.stderr.write(`slop-text: unknown role ${options.role}\n\n${USAGE}`); | |
| process.exit(2); | |
| } | |
| return options; | |
| } | |
| const SORTS = { | |
| ratio: (a, b) => b.ratio - a.ratio || b.words - a.words, | |
| words: (a, b) => b.words - a.words, | |
| sentences: (a, b) => b.sentences - a.sentences || b.words - a.words, | |
| file: (a, b) => a.file.localeCompare(b.file) || a.line - b.line, | |
| }; | |
| const excerpt = (text, full) => (full || text.length <= 160 ? text : `${text.slice(0, 157)}…`); | |
| function main() { | |
| const options = parseArguments(process.argv.slice(2)); | |
| // Default to ./src when there is one, else the working directory: no | |
| // assumption about where this file sits relative to the code it reads. | |
| const defaultRoot = fs.existsSync(path.join(process.cwd(), 'src')) ? path.join(process.cwd(), 'src') : process.cwd(); | |
| const roots = options.paths.length ? options.paths.map((p) => path.resolve(p)) : [defaultRoot]; | |
| const files = []; | |
| for (const root of roots) { | |
| if (!fs.existsSync(root)) { | |
| process.stderr.write(`slop-text: no such path ${root}\n`); | |
| process.exit(2); | |
| } | |
| collectFiles(root, files, options.exclude); | |
| } | |
| let findings = files.flatMap(scanFile); | |
| if (options.role) findings = findings.filter((finding) => finding.role === options.role); | |
| if (options.minWords) findings = findings.filter((finding) => finding.words >= options.minWords); | |
| findings.sort(SORTS[options.sort] || SORTS.ratio); | |
| if (options.json) { | |
| process.stdout.write(`${JSON.stringify({ files: files.length, findings }, null, 2)}\n`); | |
| } else { | |
| const shown = options.limit > 0 ? findings.slice(0, options.limit) : findings; | |
| for (const finding of shown) { | |
| const budget = `${finding.words}w/${finding.sentences}s vs ${finding.limit}w`; | |
| process.stdout.write(`${finding.file}:${finding.line} ${finding.role} ${finding.context} ${budget} ×${finding.ratio}\n`); | |
| process.stdout.write(` ${excerpt(finding.text, options.full)}\n\n`); | |
| } | |
| const byRole = new Map(); | |
| for (const finding of findings) byRole.set(finding.role, (byRole.get(finding.role) || 0) + 1); | |
| const roleSummary = [...byRole.entries()] | |
| .sort((a, b) => b[1] - a[1]) | |
| .map(([role, count]) => `${role} ${count}`) | |
| .join(', '); | |
| process.stdout.write(`${findings.length} findings in ${files.length} files`); | |
| if (shown.length < findings.length) process.stdout.write(` (showing ${shown.length}; --limit 0 for all)`); | |
| process.stdout.write(`\n${roleSummary || 'nothing over budget'}\n`); | |
| } | |
| if (options.max !== null && findings.length > options.max) { | |
| process.stderr.write(`\nslop-text: ${findings.length} findings exceeds --max ${options.max}\n`); | |
| process.exit(1); | |
| } | |
| } | |
| export { ROLES, countWords, countSentences, looksLikeCopy, scanFile, collectFiles }; | |
| if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { | |
| main(); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment