Files
zeekay 2441f09dcd ratchet: pnpm ratchet lowers hanzo-design.allow.json to what the tree owes, and never raises it
Counts come from the linter; a rule at zero loses its key; a rule over its
allowance stops the script with the lint's own list. --check fails when the
file is higher than the tree owes.

Claude-Session: https://claude.ai/code/session_017vrVus6PPBgP5YRzKV1iqW
2026-10-26 03:49:43 -07:00

69 lines
2.9 KiB
TypeScript

/**
* Lowers hanzo-design.allow.json to what the tree now owes, and never raises it.
*
* `pnpm lint:design` fails when a rule's count is over its allowance and says
* "lower it to N" when it is under. This does the lowering, so a fix and its
* allowance move in one commit and nobody types a number: the counts come from
* the linter itself, a rule at zero loses its key (a rule with no entry is
* allowed zero), and a rule over its allowance stops the script with the lint's
* own list, so a ratchet is never loosened to make a build pass.
*
* pnpm ratchet write the lower allowance
* pnpm ratchet --check exit 1 when the file is higher than the tree owes
*/
import { spawnSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
const FILE = 'hanzo-design.allow.json'
const TARGETS = ['components', 'app', 'lib']
const check = process.argv.includes('--check')
const lint = spawnSync('pnpm', ['exec', 'hanzo-design-lint', ...TARGETS], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
const out = `${lint.stdout ?? ''}${lint.stderr ?? ''}`
/** The linter's own count per rule: every rule with a finding prints `rule (N, …)` once. */
const found: Record<string, number> = {}
for (const m of out.matchAll(/^([a-z][a-z-]*)\s{2}\((\d+),/gm)) found[m[1]] = Number(m[2])
if (!/hanzo-design-lint: \d+ files|violation\(s\) across \d+ files/.test(out)) {
console.error(out.trim() || 'hanzo-design-lint printed nothing')
console.error('\nratchet: the linter did not finish, so nothing was written')
process.exit(1)
}
const allow = JSON.parse(readFileSync(FILE, 'utf8')) as Record<string, string | number>
const rules = Object.keys(allow).filter((k) => !k.startsWith('_'))
const next: Record<string, string | number> = {}
const over: string[] = []
const lowered: string[] = []
for (const [key, value] of Object.entries(allow)) {
if (key.startsWith('_')) {
next[key] = value
continue
}
const owes = found[key] ?? 0
if (owes > Number(value)) over.push(`${key}: ${owes} found, ${value} allowed`)
if (owes < Number(value)) lowered.push(`${key}: ${value} -> ${owes}`)
if (owes > 0) next[key] = Math.min(owes, Number(value))
}
// A rule the file does not name is allowed zero; one that has findings and no key is over.
for (const [rule, n] of Object.entries(found)) if (!rules.includes(rule)) over.push(`${rule}: ${n} found, 0 allowed`)
if (over.length) {
console.error(out.trim())
console.error(`\nratchet: over the allowance, not loosening it:\n ${over.join('\n ')}`)
process.exit(1)
}
if (!lowered.length) {
console.log('ratchet: the allowance is what the tree owes')
process.exit(0)
}
if (check) {
console.error(`ratchet: the allowance is higher than the tree owes; run \`pnpm ratchet\`:\n ${lowered.join('\n ')}`)
process.exit(1)
}
writeFileSync(FILE, `${JSON.stringify(next, null, 2)}\n`)
console.log(`ratchet: lowered ${FILE}:\n ${lowered.join('\n ')}`)