Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
pnpm changeset version

# Sync version into markdown/MDX files that hardcode it
node scripts/sync-version-in-docs.js "$OLD_CLI_VERSION"
pnpm sync-version -- "$OLD_CLI_VERSION"

# Get new CLI version after changesets
CLI_VERSION=$(node -e "console.log(require('./apps/pair-cli/package.json').version)")
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"clean": "pnpm -w -r turbo prune && turbo clean && find . -type d \\( -name node_modules -o -name .turbo -o -name dist -o -name coverage \\) -exec rm -rf {} + 2>/dev/null || true; find . -type d -path './scripts/workflows/release' -prune -o -type d -name release -exec rm -rf {} + 2>/dev/null || true; find . -type f -name \"*tsbuildinfo\" -exec rm -f {} + 2>/dev/null || true",
"a11y:report": "turbo a11y:report",
"a11y:report:html": "turbo a11y:report:html",
"hygiene:check": "node scripts/code-hygiene-check.js",
"hygiene:check": "pnpm --filter @pair/dev-tools code-hygiene:check",
"sync-version": "pnpm --filter @pair/dev-tools sync-version",
"docs:staleness": "pnpm --filter @pair/website docs:staleness",
"skills:conformance": "pnpm --filter @pair/knowledge-hub skills:conformance",
"dup:check": "jscpd apps packages",
Expand Down
20 changes: 20 additions & 0 deletions packages/dev-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# @pair/dev-tools

Pair's own gate/tooling scripts, as tested TypeScript modules — the reference pattern for repo quality gates (per [ADL 2026-07-13-gate-tooling-code-in-tested-modules](../../.pair/adoption/decision-log/2026-07-13-gate-tooling-code-in-tested-modules.md)).

## Tools

| Script | Module | Purpose |
| --------------------- | ----------------------------- | ------------------------------------------------------------------------ |
| `code-hygiene:check` | `src/code-hygiene-check.ts` | Fails if suppression markers (`@ts-ignore`, `eslint-disable`, `.skip`) are committed |
| `sync-version` | `src/sync-version-in-docs.ts` | Detects/rewrites hardcoded CLI version strings across `.md`/`.mdx` docs |

Both are runnable via the repo-root scripts (`pnpm hygiene:check`, `pnpm sync-version -- <old-version>`), which delegate here (`pnpm --filter @pair/dev-tools <script>`).

## Scope

This package owns the genuinely **repo-wide, no-package-affinity** gates. Three related tools — `docs-staleness-check` (`apps/website/lib/`), `skills-conformance-check` and `check-broken-links` (`packages/knowledge-hub/src/tools/`) — stay in their owning packages: each needs that package's own context (the website's docs tree, knowledge-hub's dataset) and moving them here would trade a clean path resolution for a deep relative reach into a sibling package's internals. `scripts/perf/benchmark-update-link.js` also stays put (perf benchmark, not a correctness gate).

## Conventions

Every module here follows the shape: logic as exported, unit-tested pure functions; a thin `main()` CLI wrapper behind a `require.main === module` guard. Tests are white-box (import the module directly) and never spawn the script as a subprocess — see the ADL linked above.
32 changes: 32 additions & 0 deletions packages/dev-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@pair/dev-tools",
"version": "0.4.3",
"description": "Pair's own gate/tooling scripts (code-hygiene, sync-version) as tested TypeScript modules — the reference pattern for repo quality gates.",
"private": true,
"scripts": {
"prettier:fix": "prettier-fix",
"prettier:check": "prettier-check",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"ts:check": "tsc --noEmit",
"lint": "lint",
"lint:fix": "lint-fix",
"mdlint:check": "markdownlint-check",
"mdlint:fix": "markdownlint-fix",
"code-hygiene:check": "ts-node src/code-hygiene-check.ts",
"sync-version": "ts-node src/sync-version-in-docs.ts"
},
"devDependencies": {
"@pair/eslint-config": "workspace:*",
"@pair/prettier-config": "workspace:*",
"@pair/markdownlint-config": "workspace:*",
"@pair/ts-config": "workspace:*",
"@types/node": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"ts-node": "catalog:"
},
"prettier": "@pair/prettier-config"
}
80 changes: 80 additions & 0 deletions packages/dev-tools/src/code-hygiene-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest'
import {
PATTERNS,
FILE_GLOBS,
buildGitGrepCommand,
parseGrepOutput,
scanPattern,
runHygieneCheck,
type HygienePattern,
} from './code-hygiene-check'

// Test fixtures below use synthetic marker names ("marker-one" etc.), not the real
// suppression strings (split in the module under test — @ts- + ignore, etc.) — this
// file is itself scanned by *.ts globs, so it must not contain the literal markers.

describe('buildGitGrepCommand', () => {
it('builds a git grep invocation with quoted globs and a self-exclude pathspec', () => {
// Globs are joined with ' -- ' (matches the original script verbatim); git
// tolerates the repeated separator — verified against a real git grep.
const cmd = buildGitGrepCommand('marker-one', ['*.ts', '*.tsx'], 'scripts/self.ts')
expect(cmd).toBe('git grep -n "marker-one" -- "*.ts" -- "*.tsx" \':!scripts/self.ts\'')
})
})

describe('parseGrepOutput', () => {
it('parses one match per line into " file:line" entries', () => {
const out = 'src/a.ts:10:// marker-one\nsrc/b.ts:22:// marker-one reason\n'
expect(parseGrepOutput(out)).toEqual([' src/a.ts:10', ' src/b.ts:22'])
})

it('drops empty lines and trims trailing newline noise', () => {
expect(parseGrepOutput('\n\n')).toEqual([])
})
})

describe('scanPattern', () => {
const pattern: HygienePattern = { label: 'marker-one', regex: 'marker-one' }

it('returns formatted matches from the injected exec function', () => {
const exec = vi.fn(() => 'src/a.ts:5:// marker-one\n')
const matches = scanPattern(pattern, FILE_GLOBS, 'self.ts', exec)
expect(matches).toEqual([' src/a.ts:5'])
expect(exec).toHaveBeenCalledWith(buildGitGrepCommand(pattern.regex, FILE_GLOBS, 'self.ts'))
})

it('treats a thrown error (git grep exit 1, no matches) as the happy path — empty result', () => {
const exec = vi.fn(() => {
throw new Error('exit 1')
})
expect(scanPattern(pattern, FILE_GLOBS, 'self.ts', exec)).toEqual([])
})
})

describe('runHygieneCheck', () => {
it('aggregates violations across patterns and totals the match count', () => {
const patterns: HygienePattern[] = [
{ label: 'marker-one', regex: 'marker-one' },
{ label: 'marker-two', regex: 'marker-two' },
{ label: 'marker-three', regex: 'marker-three' },
]
const exec: (cmd: string) => string = cmd => {
if (cmd.includes('marker-one')) return 'src/a.ts:1:x\nsrc/b.ts:2:x\n'
if (cmd.includes('marker-two')) return 'src/c.ts:3:x\n'
throw new Error('exit 1') // marker-three: no matches
}
const { violations, total } = runHygieneCheck(patterns, FILE_GLOBS, 'self.ts', exec)
expect(total).toBe(3)
expect(violations.get('marker-one')).toEqual([' src/a.ts:1', ' src/b.ts:2'])
expect(violations.get('marker-two')).toEqual([' src/c.ts:3'])
expect(violations.has('marker-three')).toBe(false)
})

it('reports zero total when nothing matches', () => {
const { violations, total } = runHygieneCheck(PATTERNS, FILE_GLOBS, 'self.ts', () => {
throw new Error('exit 1')
})
expect(total).toBe(0)
expect(violations.size).toBe(0)
})
})
135 changes: 135 additions & 0 deletions packages/dev-tools/src/code-hygiene-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Code Hygiene Check — prevents suppression markers from entering the codebase.
*
* The LOGIC lives here as individually exported, unit-tested functions (see
* code-hygiene-check.test.ts, white-box, exec injected — no real git repo needed).
* The `main()` block is a thin CLI wrapper run via `ts-node src/code-hygiene-check.ts`
* (package script `code-hygiene:check`, delegated from the repo-root `hygiene:check`
* script). Exit 0 = clean, Exit 1 = violations found.
*
* Runs `git grep` with an explicit `cwd` (the repo root, resolved from this file's
* location: packages/dev-tools/src -> packages/dev-tools -> packages -> repo root,
* up 3) so the scan behaves identically regardless of the caller's working
* directory (e.g. `pnpm --filter` sets cwd to the package dir, not the repo root).
*/
import { execSync } from 'child_process'
import { resolve } from 'path'

const REPO_ROOT = resolve(__dirname, '..', '..', '..')

// Path of this module, relative to the repo root — excluded from its own scan so
// the patterns below (deliberately split so they don't match this file) never
// trip on the string literals that define them.
const SELF_EXCLUDE = 'packages/dev-tools/src/code-hygiene-check.ts'

export interface HygienePattern {
label: string
regex: string
}

// Patterns are split so this file does not match itself.
export const PATTERNS: HygienePattern[] = [
{ label: '@ts-ignore', regex: '@ts-' + 'ignore' },
{ label: '@ts-nocheck', regex: '@ts-' + 'nocheck' },
{ label: 'eslint-disable', regex: 'eslint-' + 'disable' },
{ label: 'test.skip', regex: '\\btest\\.' + 'skip\\b' },
{ label: 'it.skip', regex: '\\bit\\.' + 'skip\\b' },
{ label: 'describe.skip', regex: '\\bdescribe\\.' + 'skip\\b' },
]

export const FILE_GLOBS = ['*.ts', '*.tsx', '*.js', '*.cjs', '*.mjs']

/** Build the `git grep` invocation for one pattern (exported for direct assertion). */
export function buildGitGrepCommand(
regex: string,
fileGlobs: string[],
selfExclude: string,
): string {
const globsArg = fileGlobs.map(g => `"${g}"`).join(' -- ')
return `git grep -n "${regex}" -- ${globsArg} ':!${selfExclude}'`
}

/** Parse raw `git grep -n` stdout into formatted ` file:line` entries. */
export function parseGrepOutput(out: string): string[] {
return out
.trim()
.split('\n')
.filter(Boolean)
.map(line => {
const sep = line.indexOf(':')
const sep2 = line.indexOf(':', sep + 1)
const file = line.substring(0, sep)
const lineNum = line.substring(sep + 1, sep2)
return ` ${file}:${lineNum}`
})
}

export type Exec = (cmd: string) => string

function defaultExec(cmd: string): string {
return execSync(cmd, { cwd: REPO_ROOT, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
}

/** Scan one pattern; `git grep` exits 1 with no output when there are no matches — that's the happy path. */
export function scanPattern(
pattern: HygienePattern,
fileGlobs: string[] = FILE_GLOBS,
selfExclude: string = SELF_EXCLUDE,
exec: Exec = defaultExec,
): string[] {
try {
const out = exec(buildGitGrepCommand(pattern.regex, fileGlobs, selfExclude))
return parseGrepOutput(out)
} catch {
return []
}
}

export interface RunResult {
violations: Map<string, string[]>
total: number
}

/** Run every pattern and aggregate results. */
export function runHygieneCheck(
patterns: HygienePattern[] = PATTERNS,
fileGlobs: string[] = FILE_GLOBS,
selfExclude: string = SELF_EXCLUDE,
exec: Exec = defaultExec,
): RunResult {
const violations = new Map<string, string[]>()
let total = 0
for (const pattern of patterns) {
const matches = scanPattern(pattern, fileGlobs, selfExclude, exec)
if (matches.length) {
violations.set(pattern.label, matches)
total += matches.length
}
}
return { violations, total }
}

/** Thin CLI wrapper: print the report and set the exit code. */
export function main(): void {
const { violations, total } = runHygieneCheck()

console.log('Code Hygiene Check')
console.log('==================')

if (total === 0) {
console.log('PASS — no violations')
process.exit(0)
} else {
console.log(`FAIL — ${total} violation${total > 1 ? 's' : ''}\n`)
for (const [label, matches] of violations) {
console.log(`${label} (${matches.length}):`)
for (const m of matches) console.log(m)
console.log()
}
process.exit(1)
}
}

if (require.main === module) {
main()
}
Loading
Loading