check-stray-components.mjs

Shared component checks

Tier 3designengineeringBlocks the commit

Find shared components left in route folders and duplicate component names. Require a reason and an expiry date for exceptions.

Add to your project
Add "Shared component checks" from ntent to this repo.

Fetch https://ntent.app/r/f/check-stray-components as plain text. Write it verbatim to scripts/check-stray-components.mjs. Then make exactly these edits and no others: Point CONFIG at this repo’s route and shared-component directories if they are not src/app and src/components.

Then check it: Import a private component from a second route and the check tells you to promote it. Allowlist it and the reason and date print on every run afterwards, rather than the finding disappearing. Backdate the entry and it fails.

Reads https://ntent.app/r/f/check-stray-components

Read the code
check-stray-components.mjsscripts/check-stray-components.mjs
335 lines
#!/usr/bin/env node
/**
 * Design-system drift gate: a reusable component should not live inside one
 * route's private folder.
 *
 * Two separate checks, because they fail differently.
 *
 * CHECK 1, REUSE. A component defined under a route's private folder
 * (src/app/**\/_components/) but imported by two or more other places is
 * shared in practice while living somewhere private by name. The next person
 * looking for it will not find it, and will write a second one.
 *
 * CHECK 2, DUPLICATES. The same component name defined in two or more files.
 * This is the worse failure, because there is no single source at all, and
 * check 1 cannot see it: check 1 works by counting importers, and two
 * copy-pasted components have zero importers each. In the Synacor repo a row
 * component was defined twice, in two different sheets, with the same anatomy
 * and the same explanatory comment pasted into both. Every gate was green.
 *
 * Neither check is a proof. Both are prompts for a human decision: promote it,
 * or add it to ALLOWLIST with a reason and a date. The reason is the point. An
 * allowlist entry with no explanation becomes permanent within a month, so the
 * allowlist is a mechanism rather than a courtesy: both fields are required,
 * every accepted entry prints on every run, and the date is enforced. The
 * obvious implementation, `if (ALLOWLIST[name]) continue`, is that same warning
 * written into the code: skipped in silence, with a reason nothing ever reads.
 *
 * WHAT IT DOES NOT PROVE. That the components it passes are well factored, or
 * that an accepted exception is still a good idea. It proves that every
 * exception has a name, a reason and a date somebody agreed to.
 *
 * Usage:
 *   node scripts/check-stray-components.mjs           report, always exit 0
 *   node scripts/check-stray-components.mjs --check   exit 1 on anything unaccepted
 *
 * Exit 2 in either mode when an allowlist entry has no reason or no date. That
 * is a broken config rather than a finding, so it refuses to run at all.
 */
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, basename, relative, sep } from 'node:path'

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

const CONFIG = {
  // Folders whose components are meant to be private to one route or feature.
  // A component here that gets shared is the drift this catches.
  privateDirs: ['src/app', 'src/features'],
  // Where a promoted component belongs.
  sharedDir: 'src/components',
  // Everything scanned for imports, to count who uses what.
  scanDirs: ['src'],
  ignoreDirs: ['node_modules', '.next', 'dist', 'build', '.git'],
  // Names that look like components but are compositions, not primitives.
  // A page or a layout is not something you promote.
  compositionSuffixes: ['Page', 'Layout', 'Template', 'Provider', 'Boundary', 'Route'],
  // Files that re-export or catalogue rather than consume. An index barrel
  // importing something is not evidence of reuse.
  barrelNames: ['index.ts', 'index.tsx'],
}

/**
 * Intentional exceptions. Every entry carries WHY this is genuinely local and
 * WHEN it stops being acceptable, because "nobody has got round to it" is not a
 * reason and an exception with no date never leaves.
 *
 * Both fields are enforced rather than requested. An entry missing either one
 * stops the script dead, and every accepted entry is printed on every run: an
 * exception nobody sees again is a permanent one.
 */
const ALLOWLIST = {
  // InvoiceRow: {
  //   reason:
  //     'Bound to the billing fixtures and the invoice status enum, not a tenant-neutral ' +
  //     'primitive. Shared by the list and the detail drawer on purpose so the two never drift.',
  //   until: '2026-06-30',
  // },
}

// --------------------------------------------------------------- HELPERS ---

function walk(dir, acc = []) {
  let entries
  try {
    entries = readdirSync(dir)
  } catch {
    return acc
  }
  for (const entry of entries) {
    if (CONFIG.ignoreDirs.includes(entry)) continue
    const full = join(dir, entry)
    if (statSync(full).isDirectory()) walk(full, acc)
    else if (/\.(tsx|jsx)$/.test(entry)) acc.push(full)
  }
  return acc
}

/**
 * Component names declared in a file. Matches the four shapes people write:
 * export function X, export const X = , function X, const X = , where X is
 * PascalCase. Non-exported declarations are included on purpose: check 2 needs
 * them, since a copy-pasted component is usually not exported.
 */
function componentsDeclaredIn(file) {
  const text = readFileSync(file, 'utf8')
  const found = new Map() // name -> exported?
  const patterns = [
    { re: /export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9]*)/g, exported: true },
    { re: /export\s+const\s+([A-Z][A-Za-z0-9]*)\s*[:=]/g, exported: true },
    { re: /^\s*function\s+([A-Z][A-Za-z0-9]*)/gm, exported: false },
    { re: /^\s*const\s+([A-Z][A-Za-z0-9]*)\s*[:=]\s*(?:\(|function|React\.memo|forwardRef)/gm, exported: false },
  ]
  for (const { re, exported } of patterns) {
    re.lastIndex = 0
    let m
    while ((m = re.exec(text))) {
      const name = m[1]
      if (CONFIG.compositionSuffixes.some((s) => name.endsWith(s))) continue
      if (!found.has(name) || exported) found.set(name, exported)
    }
  }
  // Only count it as a component if the file actually renders JSX. A PascalCase
  // const in a plain .ts helper is a type or a constant, not a component.
  if (!/<[A-Za-z]/.test(text)) return new Map()
  return found
}

/** Named imports in a file, as a flat list of imported identifiers. */
function importsIn(file) {
  const text = readFileSync(file, 'utf8')
  const found = new Set()
  const re = /import\s+(?:type\s+)?\{([^}]+)\}\s+from/g
  let m
  while ((m = re.exec(text))) {
    for (const part of m[1].split(',')) {
      const name = part.trim().split(/\s+as\s+/)[0].trim()
      if (/^[A-Z]/.test(name)) found.add(name)
    }
  }
  return found
}

function isPrivate(file) {
  return CONFIG.privateDirs.some((d) => file.startsWith(d + sep) || file.startsWith(d + '/'))
}

/** Reason text, wrapped so a long one stays readable in a terminal. */
function wrap(text, width = 72) {
  const lines = []
  let line = ''
  for (const word of String(text).split(/\s+/)) {
    if (line && line.length + word.length + 1 > width) {
      lines.push(line)
      line = word
    } else {
      line = line ? `${line} ${word}` : word
    }
  }
  if (line) lines.push(line)
  return lines
}

/**
 * Refuse to run on a malformed allowlist. This is exit 2, not a finding: a
 * finding is something in the codebase, and this is the check itself being
 * broken. Failing loudly here is the difference between an allowlist and a
 * place things go to disappear.
 */
function validateAllowlist() {
  for (const [name, rec] of Object.entries(ALLOWLIST)) {
    if (!rec || !rec.reason || !rec.until) {
      console.error(`\n  Allowlist entry "${name}" has no reason or no until date.`)
      console.error(`  An entry without both is permanent, and "nobody has got round to`)
      console.error(`  it" is not a reason. Add both, or delete the entry and do the work.\n`)
      process.exit(2)
    }
    // Dates are compared as strings, so the format is load-bearing. 'soon' or
    // '1 June' would sort as never expired and the entry would be permanent
    // while looking dated.
    if (!/^\d{4}-\d{2}-\d{2}$/.test(rec.until)) {
      console.error(`\n  Allowlist entry "${name}" has until: "${rec.until}".`)
      console.error(`  It has to be YYYY-MM-DD. Anything else never expires.\n`)
      process.exit(2)
    }
  }
}

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

function main() {
  const strict = process.argv.includes('--check')
  const today = new Date().toISOString().slice(0, 10)
  validateAllowlist()
  const files = CONFIG.scanDirs.filter(existsSync).flatMap((d) => walk(d))

  // name -> [files that declare it]
  const declaredIn = new Map()
  for (const file of files) {
    for (const [name] of componentsDeclaredIn(file)) {
      if (!declaredIn.has(name)) declaredIn.set(name, [])
      declaredIn.get(name).push(file)
    }
  }

  // name -> [files that import it], excluding barrels and the declaring file.
  const importedBy = new Map()
  for (const file of files) {
    if (CONFIG.barrelNames.includes(basename(file))) continue
    for (const name of importsIn(file)) {
      if (!declaredIn.has(name)) continue
      if (declaredIn.get(name).includes(file)) continue
      if (!importedBy.has(name)) importedBy.set(name, [])
      importedBy.get(name).push(file)
    }
  }

  const findings = []
  const accepted = [] // allowlisted, and printed every run rather than skipped

  // CHECK 1: private but reused.
  for (const [name, sites] of declaredIn) {
    if (sites.length !== 1) continue // handled by check 2
    const home = sites[0]
    if (!isPrivate(home)) continue
    const users = importedBy.get(name) || []
    if (users.length >= 2) {
      if (ALLOWLIST[name]) {
        accepted.push({ kind: 'REUSED-BUT-PRIVATE', name, where: rel(home) })
        continue
      }
      findings.push({
        kind: 'REUSED-BUT-PRIVATE',
        name,
        detail:
          `    defined in ${rel(home)}\n` +
          `    imported by ${users.length}: ${users.map(rel).join(', ')}\n` +
          `    fix: move it to ${CONFIG.sharedDir}/, or add it to ALLOWLIST with the\n` +
          `         reason it is genuinely local to one route, and a date`,
      })
    }
  }

  // CHECK 2: defined more than once.
  for (const [name, sites] of declaredIn) {
    if (sites.length < 2) continue
    if (ALLOWLIST[name]) {
      accepted.push({ kind: 'DEFINED-TWICE', name, where: sites.map(rel).join(', ') })
      continue
    }
    findings.push({
      kind: 'DEFINED-TWICE',
      name,
      detail:
        `    defined in ${sites.length} files: ${sites.map(rel).join(', ')}\n` +
        `    There is no single source for this component. Copy-paste is the worse\n` +
        `    failure: the two copies will drift and nobody will notice.\n` +
        `    fix: keep one, in ${CONFIG.sharedDir}/, and import it in both places`,
    })
  }

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

  // Accepted exceptions print FIRST, on every run, whether or not anything else
  // was found. This is the half that used to be a silent `continue`: an
  // exception you never see again is one nobody will ever remove.
  let expired = 0
  if (accepted.length) {
    console.log(`\n  Accepted, each with a reason and a date:\n`)
    for (const a of accepted) {
      const rec = ALLOWLIST[a.name]
      console.log(`  [known] ${a.name}   ${a.kind}, until ${rec.until}`)
      console.log(`          ${a.where}`)
      for (const line of wrap(rec.reason, 66)) console.log(`          ${line}`)
      if (today > rec.until) {
        expired++
        console.log(`          PAST ITS DATE. Do the work, or agree a new date and write`)
        console.log(`          down what changed. Moving the date on its own is how an`)
        console.log(`          exception becomes permanent.`)
      }
      console.log()
    }
  }

  // An entry that matched nothing is dead config. The drift it accepted is gone,
  // or the name changed, and either way the entry now accepts nothing while
  // reading as an agreed exception. Printed, not fatal: deleting it is the work.
  const unused = Object.keys(ALLOWLIST).filter((name) => !accepted.some((a) => a.name === name))
  if (unused.length) {
    console.log(`  Allowlist entries that matched nothing this run:\n`)
    for (const name of unused) {
      console.log(`  [stale] ${name}`)
      console.log(`          Nothing here is that component any more. Delete the entry,`)
      console.log(`          so the allowlist keeps meaning what it says.`)
      console.log()
    }
  }

  if (!findings.length && !expired) {
    if (accepted.length) {
      console.log(
        `  components: none new. ${accepted.length} accepted, listed above with a reason and a date.\n`,
      )
    } else {
      console.log(`components: ok. ${declaredIn.size} component(s) across ${files.length} files.`)
    }
    process.exit(0)
  }

  if (findings.length) {
    if (!accepted.length && !unused.length) console.log()
    console.log(`  components: ${findings.length} finding(s)\n`)
    for (const f of findings) {
      console.log(`  ${f.kind}  ${f.name}`)
      console.log(f.detail)
      console.log()
    }
    console.log(`  These are prompts for a decision, not proofs. Promote, or allowlist`)
    console.log(`  with a reason AND a date in ${rel('scripts/check-stray-components.mjs')}.`)
    console.log()
  }

  // An expired entry counts as unaccepted, because that is what it is. The two
  // numbers are separate so nobody reads a long accepted list as a clean run.
  console.log(
    `  components: ${findings.length + expired} unaccepted, ${accepted.length - expired} accepted.\n`,
  )

  process.exit(strict ? 1 : 0)
}

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

main()

Success check: Import a private component from a second route and the check tells you to promote it. Allowlist it and the reason and date print on every run afterwards, rather than the finding disappearing. Backdate the entry and it fails.

Included in