check-clip-comments.mjs

Overflow clip comment checks

Tier 3designengineeringBlocks the commit

An overflow clip nobody can explain, which cuts off focus rings, shadows and descenders without an error. Each clip needs a comment saying what it holds in.

CHECK-CLIP-COMMENTS.MJSBLOCKS THE COMMIT COMPONENTS STYLESHEETS COMMIT UNEXPLAINED CLIP
Add this to your project
Add "Overflow clip comment checks" from ntent to this repo.

Fetch https://ntent.app/r/f/check-clip-comments as plain text. Write it verbatim to scripts/check-clip-comments.mjs. Then make exactly these edits and no others: CONFIG.ignorePaths skips src/components/ui, where shadcn vendors its primitives. Delete that entry once those files are yours, and point CONFIG.scanDirs at this repo’s source if it is not src. It needs waivers.mjs. If this repo does not have it, stop and tell me.

Then check it: Write overflow-hidden on a component with no comment near it. The commit is refused, naming the file and line. Put a comment above it saying what it clips and the same commit goes through. A file under src/components/ui is skipped, and every run says so.

Reads https://ntent.app/r/f/check-clip-comments

Read the code
check-clip-comments.mjsscripts/check-clip-comments.mjs
336 lines
#!/usr/bin/env node
/**
 * Clip gate: every overflow clip says what it clips.
 *
 * `overflow: hidden` cuts off everything its subtree paints outside its own box.
 * That is usually the point: an image rounded to its card, a progress fill held
 * inside its track. It also cuts off whatever nobody was thinking about when it
 * was written: the focus ring on a button two levels down, the shadow on a
 * hovered row, the descender on a g in a tight line box, the menu that opens
 * past the card's edge. None of those is an error. The ring simply is not
 * there, on one screen, for keyboard users, and every gate stays green.
 *
 * The clip that starts that hunt almost never says why it exists. By the time
 * somebody finds the missing ring, nobody can tell whether the clip was holding
 * something in on purpose or was pasted in with the rest of a class string, so
 * removing it is a test run rather than a decision. A comment is the cheapest
 * thing that turns it back into a decision.
 *
 * So: every clip in the scanned .ts/.tsx/.css carries a comment on its own line
 * or directly above it. The spellings it knows:
 *
 *   CSS           overflow: hidden, overflow-x: clip, overflow: auto hidden
 *   style object  overflow: 'hidden', overflowY: 'clip', 'overflow-x': 'hidden'
 *   Tailwind      overflow-hidden, overflow-x-hidden, overflow-y-hidden,
 *                 overflow-clip, with any variant (md:, hover:, !) and in @apply
 *
 * A comment counts when it is on the clip's own line, when it is in the
 * contiguous comment block directly above that line, or when it sits directly
 * above the block that holds the clip: the CSS rule, the style key, the cva(
 * call or the JSX tag whose attributes run over several lines. Those are the
 * ways these files are already written, and the last one is not a nicety. It is
 * the case that caught an earlier version of this check: a comment above a style
 * key, with the clip as that block's second property, read as unexplained.
 *
 * PRESENCE, NOT CONTENT. No gate can read a sentence and judge whether it names
 * the right thing. That is still the whole value: the clip this began with had
 * no comment at all, and its absence is what made removing it expensive.
 *
 * WHY THIS IS NOT AN ESLINT RULE. `no-restricted-syntax` is an esquery selector
 * with no access to the comments around a node, so "fire unless it is explained"
 * is not expressible there, and the selector without that condition fires on
 * every legitimate clip in the codebase: the rule people turn off in a week.
 * ESLint also never sees a .css file, which is where half the clips are.
 *
 * WHY WAIVERS DO NOT APPLY. The finding is "nothing says what this clips", and
 * the reason on a waiver is exactly that sentence. So a waiver comment WITH a
 * reason counts as the explanation, and there is nothing left to waive or to go
 * stale. A comment that is only a tool directive does NOT count: a bare
 * `project-check-ignore`, an `eslint-disable-next-line`, a `@ts-expect-error`
 * say nothing about the clip, and letting them through would make any directive
 * a way round the rule.
 *
 * VENDORED PRIMITIVES ARE SKIPPED BY DEFAULT, AND SAID TO BE. src/components/ui
 * is where shadcn writes its primitives, and they clip on purpose all over the
 * place: the avatar to its circle, the scroll area to its viewport, the
 * accordion to its animating height. Asking for a comment on each on day one is
 * a flood of findings about code nobody here wrote, which is how a gate gets
 * bypassed. So the directory is in CONFIG.ignorePaths, and every run names what
 * it skipped rather than going quiet about it. Once you own those files, and
 * edit them as your design system rather than as upstream's, delete the entry.
 *
 * WHAT IT DOES NOT PROVE. That the comment is true, or that the clip is still
 * needed. It does not see clips spelled any other way: overflow: auto and
 * scroll (a scroll container's clip is its purpose), truncate and line-clamp-*
 * (the class name says what it clips), contain: paint, clip-path, a class built
 * at runtime (`overflow-${axis}-hidden`), or a clip inside a library's own CSS.
 * And it cannot tell you what a clip is cutting off today. Look at the screen
 * with a keyboard.
 *
 * EXIT CODES. 0 clean, 1 findings (with --check), 2 cannot check. A run that
 * scanned zero files says so in plain words instead of printing a pass, and
 * under --check that is exit 2: a vacuous pass reads exactly like a real one.
 *
 * Usage:
 *   node scripts/check-clip-comments.mjs                    report, always exit 0
 *   node scripts/check-clip-comments.mjs --check            exit 1 on an unexplained clip
 *   node scripts/check-clip-comments.mjs --check src/a.tsx  only these files or directories
 */
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, resolve, relative, extname } from 'node:path'
import { scanSource } from './lib/waivers.mjs'

// ---------------------------------------------------------------- CONFIG ---

const CONFIG = {
  scanDirs: ['src'],
  extensions: ['.ts', '.tsx', '.css'],
  ignoreDirs: ['node_modules', '.next', 'dist', 'build', 'out', 'coverage', '.git', '.turbo', 'storybook-static'],

  // Repo-relative paths skipped whole, and named as skipped on every run. The
  // default is where shadcn vendors its primitives: see VENDORED PRIMITIVES
  // above. Delete the entry the day those files are yours.
  ignorePaths: ['src/components/ui'],

  // Comments that are only a directive to a tool, and so say nothing about the
  // clip. Matched at the start of the comment. What follows a `--` or a `:` is
  // still read as a reason, so `eslint-disable-next-line x -- keeps the thumb
  // inside the track` counts and a bare `eslint-disable-next-line x` does not.
  directives: [
    /^project-check-ignore(-next-line|-start|-end)?\b/,
    /^eslint-(disable|enable)(-next-line|-line)?\b/,
    /^@ts-(ignore|expect-error|nocheck)\b/,
    /^(prettier|biome|stylelint)-(ignore|disable|enable)/,
  ],
}

// ------------------------------------------------------------------ ARGS ---

const argv = process.argv.slice(2)
const takeFlag = (name) => {
  const i = argv.indexOf(name)
  if (i === -1) return false
  argv.splice(i, 1)
  return true
}
const strict = takeFlag('--check')

// What the pre-commit hook triggers on, and filters the staged paths by, asked
// of the checker so that moving CONFIG.scanDirs moves the gate with it.
if (takeFlag('--print-config')) {
  for (const dir of CONFIG.scanDirs) console.log(`watchDir\t${dir}`)
  process.exit(0)
}
const roots = argv.length ? argv.map((p) => resolve(p)) : CONFIG.scanDirs.map((d) => resolve(d))

// ----------------------------------------------------------------- RULES ---

// The property, in CSS and in a style object. The lookbehind keeps
// text-overflow: clip out, and the line-bounded value keeps the match on the
// declaration it started on: overflow: open ? 'visible' : 'hidden' is a clip,
// overflow: auto; on the next line is not.
const PROPERTY = /(?<![\w-])overflow(?:-x|-y|X|Y)?['"]?\s*:(?!:)(?<value>[^;,{}\n]*?)\b(?<clip>hidden|clip)\b/g

// The utility, anywhere a class can be written: className, cn(), a cva variant,
// @apply. Variants and the ! marker sit outside the match. The lookahead keeps
// overflow-clip-margin out.
const UTILITY = /(?<![\w-])overflow-(?:[xy]-)?(?:hidden|clip)(?![\w-])/g

// A line that opens the block a clip sits in: `.card {`, `container: {`,
// `cva(`, `<div` with its attributes on the lines below. `return (` and `=> (`
// open a whole component, and a comment above one is about the component.
const HOLDER = /(?:[{([]|^\s*<[A-Za-z][\w.]*)\s*$/
const NOT_HOLDER = /(?:\breturn|=>)\s*\($/

function clipsIn(scan) {
  const found = new Map() // line -> the clip as written, first one per line
  const add = (index, text) => {
    const line = scan.lineOf(index)
    if (!found.has(line)) found.set(line, text)
  }
  for (const m of scan.code.matchAll(PROPERTY)) {
    // The whole declaration, up to where it ends on this line.
    const declaration = scan.code.slice(m.index).match(/^[^;,}\n]*/)[0].trim()
    // A type, not a value: overflow: 'hidden' | 'visible' in a props interface.
    // A lone bar only, so a `||` fallback to 'hidden' is still a clip.
    if (/(?<!\|)\|(?!\|)/.test(declaration)) continue
    add(m.index, declaration)
  }
  for (const m of scan.code.matchAll(UTILITY)) add(m.index, m[0])
  return found
}

/** Whether a comment says anything, once any tool directive is taken off it. */
function explains(text) {
  let body = text.replace(/^[\s*!{}]+|[\s*{}]+$/g, '').replace(/\s+/g, ' ')
  if (CONFIG.directives.some((d) => d.test(body))) {
    const reason = /(?:--|:)\s*(.+)$/.exec(body)
    body = reason ? reason[1] : ''
  }
  return /[A-Za-z]{2}/.test(body)
}

function explainer(scan, isCss) {
  const { raw } = scan.lines
  const codeLines = scan.code.split('\n')
  const count = raw.length

  // Per line: does a comment that says something start or end on it, and is the
  // line nothing BUT comment (a `{` or `}` either side is how JSX spells one).
  const says = new Array(count).fill(false)
  const commented = new Array(count).fill(false)
  for (const c of scan.comments) {
    const a = scan.lineOf(c.start)
    const b = scan.lineOf(Math.max(c.start, c.end - 1))
    for (let i = a; i <= b; i++) commented[i] = true
    if (explains(c.text)) says[a] = says[b] = true
  }
  const commentOnly = (i) => commented[i] && codeLines[i].replace(/[\s{}]/g, '') === ''

  // The contiguous comment block ending directly above line i.
  const blockAbove = (i) => {
    for (let j = i - 1; j >= 0 && commentOnly(j); j--) if (says[j]) return true
    return false
  }

  const indent = (s) => s.length - s.trimStart().length

  return (line) => {
    if (says[line] || blockAbove(line)) return true
    // The block that holds it: the nearest shallower line above, when that
    // line opens a block rather than being a sibling statement.
    const depth = indent(raw[line])
    for (let j = line - 1; j >= 0; j--) {
      if (!codeLines[j].trim()) continue
      if (indent(raw[j]) >= depth) continue
      const opener = codeLines[j].trimEnd()
      if (!HOLDER.test(opener) || NOT_HOLDER.test(opener)) return false
      // A selector list opens on its first line, not on the one with the `{`:
      // the comment above `html,` is about `html,\nbody {`. CSS only, because
      // in a style object the line ending in a comma is the sibling key above.
      let top = j
      while (isCss && top > 0 && codeLines[top - 1].trimEnd().endsWith(',')) top--
      return says[j] || blockAbove(top)
    }
    return false
  }
}

// ------------------------------------------------------------------ WALK ---

const skipped = new Set()
const ignoredPath = (file) => {
  const r = relative(process.cwd(), file)
  const hit = CONFIG.ignorePaths.find((p) => r === p || r.startsWith(`${p}/`))
  if (hit) skipped.add(hit)
  return Boolean(hit)
}

const files = []
// Files in a format this checker has no reader for. Counted so an empty run can
// say so by name, instead of reading as "nothing to find".
const UNREADABLE = ['.vue', '.svelte', '.astro']
const unreadable = []
const walk = (dir) => {
  let entries
  try {
    entries = readdirSync(dir, { withFileTypes: true })
  } catch {
    return
  }
  for (const entry of entries) {
    const full = join(dir, entry.name)
    if (entry.isDirectory()) {
      if (CONFIG.ignoreDirs.includes(entry.name)) continue
      if (ignoredPath(full)) continue
      walk(full)
    } else if (CONFIG.extensions.includes(extname(entry.name)) && !ignoredPath(full)) {
      files.push(full)
    } else if (UNREADABLE.includes(extname(entry.name)) && !ignoredPath(full)) {
      unreadable.push(full)
    }
  }
}
for (const root of roots) {
  const s = statSync(root, { throwIfNoEntry: false })
  if (!s) continue
  if (s.isDirectory()) {
    if (!ignoredPath(root)) walk(root)
  } else if (CONFIG.extensions.includes(extname(root)) && !ignoredPath(root)) {
    files.push(root)
  }
}

// ------------------------------------------------------------------ MAIN ---

const bare = []
let total = 0
for (const file of files) {
  const isCss = extname(file) === '.css'
  const scan = scanSource(readFileSync(file, 'utf8'), { isCss })
  const clips = clipsIn(scan)
  if (!clips.size) continue
  total += clips.size
  const explained = explainer(scan, isCss)
  for (const [line, text] of clips) {
    if (!explained(line)) bare.push({ file: rel(file), line, text })
  }
}
bare.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line)

// ---------------------------------------------------------------- REPORT ---

const label = `${files.length} file${files.length === 1 ? '' : 's'}`
// Named on every run, pass or fail. A directory nobody checks is a blind spot,
// and a blind spot the output never mentions is one nobody remembers choosing.
const skipLine = () => {
  if (skipped.size) {
    console.log(`  not checked, in CONFIG.ignorePaths: ${[...skipped].sort().join(', ')}.`)
  }
}

if (files.length === 0) {
  const kinds = [...new Set(unreadable.map((f) => extname(f)))].sort()
  const missing = roots.filter((r) => !statSync(r, { throwIfNoEntry: false }))
  console.log(
    `check-clip-comments: read 0 files under ${roots.map(rel).join(', ')} (it reads ${CONFIG.extensions.join(', ')}).\n` +
      (missing.length ? `  ${missing.map(rel).join(', ')} ${missing.length === 1 ? 'does' : 'do'} not exist here.\n` : '') +
      (unreadable.length
        ? `  It found ${unreadable.length} ${kinds.join(', ')} file(s) there, and does not read them: a clip written in a\n` +
          '  template or a component file of that kind is not seen.\n'
        : '') +
      '  Nothing was checked. This is not a pass, it is an empty run: set CONFIG.scanDirs to the\n' +
      '  directories that hold your code, or pass a path, or drop this check.',
  )
  skipLine()
  process.exit(strict ? 2 : 0)
}

if (!bare.length) {
  console.log(`check-clip-comments: ok. ${total} clip(s) in ${label}, each with a comment saying what it clips.`)
  skipLine()
  process.exit(0)
}

console.log(`\ncheck-clip-comments: ${bare.length} of ${total} clip(s) in ${label} say nothing about what they clip\n`)
let current = null
for (const b of bare) {
  if (b.file !== current) {
    current = b.file
    console.log(`  ${b.file}`)
  }
  console.log(`    ${String(b.line + 1).padStart(5)}:  UNEXPLAINED-CLIP  ${b.text}`)
}
console.log(
  '\n  A clip cuts off everything its subtree paints outside its box, focus rings\n' +
    '  and shadows included, so the next person has to know what it is for to know\n' +
    '  whether it can go. Say what it clips in a comment on the line, directly above\n' +
    '  it, or directly above the rule or style key that holds it.\n',
)
skipLine()
process.exit(strict ? 1 : 0)

function rel(f) {
  return relative(process.cwd(), f) || f
}

How to tell it works: Write overflow-hidden on a component with no comment near it. The commit is refused, naming the file and line. Put a comment above it saying what it clips and the same commit goes through. A file under src/components/ui is skipped, and every run says so.

Included in