Skip to content
Documentation
Hook

Hooks

What stale-docs runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
stale-docs
31 skill1 command1 hook
Install
> /plugin marketplace add SectionTN/stale-docs
> /plugin install stale-docs@stale-docs

Ships with stale-docs. Installing the plugin gets these hooks.

What fires, and when

PostToolUse

  • MatchesEdit|Write|MultiEditnode "${CLAUDE_PLUGIN_ROOT}/hooks/check-stale.js"
Read hooks/hooks.json

Where it lives

  • hooks/check-stale.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    'use strict';
    
    const fs = require('fs');
    const path = require('path');
    
    const DEFAULTS = {
      enabled: true,
      sourceGlobs: ['**/*.{js,jsx,ts,tsx,mjs,cjs,py,go,rs,java,rb,c,h,cpp,hpp}'],
      docGlobs: ['README.md', '*.md', 'docs/**/*.md'],
      ignore: [
        '**/node_modules/**',
        '**/.git/**',
        '**/dist/**',
        '**/build/**',
        '**/vendor/**',
        '**/target/**',
      ],
    };
    
    const MAX_DOC_FILES = 500;
    const MAX_SOURCE_FILES = 2000;
    const MAX_FILE_SIZE = 1024 * 1024;
    const MAX_SYMBOLS_PER_FILE = 50;
    const MAX_HOOK_FINDINGS = 20;
    const AUDIT_LIMIT = 10;
    const MIN_SYMBOL_LENGTH = 3;
    
    // orphans outrank every reference tier: the scanner proved the target is gone
    const ORPHAN_CONFIDENCE = 5;
    
    const SOURCE_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rs|java|rb|c|h|cpp|hpp)$/;
    const BACKTICK_TOKEN = /`([^`\n]+)`/g;
    const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
    
    // a doc this saturated with references is probably *about* the changed file
    const REWRITE_MIN_LINES = 3;
    const REWRITE_DENSITY = 0.3;
    
    // identifiers too generic to prove a doc refers to *this* file
    const STOP_WORDS = new Set([
      'main', 'init', 'new', 'get', 'set', 'run', 'test', 'index', 'data',
      'name', 'type', 'value', 'error', 'result', 'args', 'options', 'config',
      'default', 'start', 'stop', 'update', 'create', 'delete', 'read', 'write',
      'app', 'use', 'add', 'remove', 'list', 'item', 'key', 'state', 'props',
    ]);
    
    const SYMBOL_PATTERNS = [
      /\bexport\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/g,
      /\bexport\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g,
      /\bexport\s+(?:const|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g,
      /\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g,
      /^def\s+([A-Za-z_]\w*)/gm,
      /^(?:export\s+)?class\s+([A-Za-z_]\w*)/gm,
      /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm,
      /\bpub\s+(?:async\s+)?fn\s+([A-Za-z_]\w*)/g,
      /\bpub\s+(?:struct|enum|trait)\s+([A-Za-z_]\w*)/g,
    ];
    
    const FLAG_PATTERN = /["'`](--[a-z][a-z0-9-]{2,})["'`]/g;
    
    function escapeRegExp(s) {
      return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }
    
    function globToRegExp(glob) {
      let re = '';
      let i = 0;
      while (i < glob.length) {
        const c = glob[i];
        if (c === '*') {
          if (glob[i + 1] === '*') {
            if (glob[i + 2] === '/') {
              re += '(?:[^/]+/)*';
              i += 3;
            } else {
              re += '.*';
              i += 2;
            }
          } else {
            re += '[^/]*';
            i += 1;
          }
        } else if (c === '{') {
          const end = glob.indexOf('}', i);
          if (end === -1) {
            re += '\\{';
            i += 1;
          } else {
            const parts = glob.slice(i + 1, end).split(',').map(escapeRegExp);
            re += '(?:' + parts.join('|') + ')';
            i = end + 1;
          }
        } else if (c === '?') {
          re += '[^/]';
          i += 1;
        } else {
          re += escapeRegExp(c);
          i += 1;
        }
      }
      return new RegExp('^' + re + '$');
    }
    
    function compileGlobs(globs) {
      return globs.map(globToRegExp);
    }
    
    function matchesAny(relPath, regexps) {
      return regexps.some((re) => re.test(relPath));
    }
    
    function loadConfig(root) {
      try {
        const raw = fs.readFileSync(path.join(root, '.stale-docs.json'), 'utf8');
        const parsed = JSON.parse(raw);
        return {
          enabled: parsed.enabled !== false,
          sourceGlobs: Array.isArray(parsed.sourceGlobs) ? parsed.sourceGlobs : DEFAULTS.sourceGlobs,
          docGlobs: Array.isArray(parsed.docGlobs) ? parsed.docGlobs : DEFAULTS.docGlobs,
          ignore: Array.isArray(parsed.ignore) ? parsed.ignore : DEFAULTS.ignore,
        };
      } catch {
        return DEFAULTS;
      }
    }
    
    function walk(root, ignoreRes, limit) {
      const out = [];
      const stack = ['.'];
      while (stack.length && out.length < limit) {
        const rel = stack.pop();
        let entries;
        try {
          entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
        } catch {
          continue;
        }
        for (const entry of entries) {
          const childRel = rel === '.' ? entry.name : rel + '/' + entry.name;
          if (matchesAny(entry.isDirectory() ? childRel + '/' : childRel, ignoreRes)) continue;
          if (entry.isDirectory()) {
            stack.push(childRel);
          } else if (entry.isFile()) {
            out.push(childRel);
            if (out.length >= limit) break;
          }
        }
      }
      return out;
    }
    
    function readSmallFile(absPath) {
      try {
        const stat = fs.statSync(absPath);
        if (!stat.isFile() || stat.size > MAX_FILE_SIZE) return null;
        return fs.readFileSync(absPath, 'utf8');
      } catch {
        return null;
      }
    }
    
    function extractSymbols(content) {
      const symbols = new Set();
      for (const pattern of SYMBOL_PATTERNS) {
        pattern.lastIndex = 0;
        let m;
        while ((m = pattern.exec(content)) !== null) {
          const name = m[1];
          if (name.length >= MIN_SYMBOL_LENGTH && !STOP_WORDS.has(name.toLowerCase())) {
            symbols.add(name);
          }
          if (symbols.size >= MAX_SYMBOLS_PER_FILE) return [...symbols];
        }
      }
      FLAG_PATTERN.lastIndex = 0;
      let m;
      while ((m = FLAG_PATTERN.exec(content)) !== null) {
        symbols.add(m[1]);
        if (symbols.size >= MAX_SYMBOLS_PER_FILE) break;
      }
      return [...symbols];
    }
    
    function buildSymbolRegExp(symbols) {
      if (!symbols.length) return null;
      const alternatives = symbols
        .slice()
        .sort((a, b) => b.length - a.length)
        .map(escapeRegExp)
        .join('|');
      return new RegExp('(?<![\\w$-])(' + alternatives + ')(?![\\w$])');
    }
    
    function collectBacktickTokens(line) {
      BACKTICK_TOKEN.lastIndex = 0;
      const tokens = [];
      let m;
      while ((m = BACKTICK_TOKEN.exec(line)) !== null) tokens.push(m[1]);
      return tokens;
    }
    
    // path-like means a concrete repo-relative file name: not a glob, not a
    // phrase, not an absolute path or slash command, and not a URL fragment or
    // directory mention, which is why a file extension is required
    function looksLikePath(token) {
      if (/[\s*?{}]/.test(token) || token.startsWith('/')) return false;
      if (!/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(token)) return false;
      return token.in

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withstale-docs

Your docs can never lie again. You change code and the README quietly stops being true. Nobody notices until a stranger does.

Get the whole plugin
Stats
3
Stars
1
Forks
Maintained
Maintenance
JavaScript
Language
MIT
License
1mo ago
Last commit
2mo ago
Created

Repo: SectionTN/stale-docs