///
/**
* Write `.gates-count.json`: what the gate finds, per rule — errors, warnings
* and escapes (seam S3 in docs/GATES.md §16). Written once (§23): until UIG-10
* Peek, Ship and this package each carried the same script, differing only in
* the repo's name and which rules it seeded.
*
* Runs after the gate lint (npm's `postlint:rules`), so CI runs it too. The
* file is committed: nothing compares it yet, and the later ratchet needs the
* history to start at the first commit. It lints with the gate's own config,
* with escapes reported, so an escape is counted rather than silent. The file
* is rewritten only when a count changes, so running the lint does not leave a
* changed file behind every time.
*
* It fails when an `eslint-disable` switched a gate rule off: that is not an
* escape, and the escape count cannot see it.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ESLint as ESLintClass } from 'eslint'
import { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, type GateCount } from '../eslint/index'
export interface CountOptions {
/** The folder the gate lints from, where `.gates-count.json` is written. */
root?: string
/** The name written into the file: `peek`, `ship`, `estiva-ui`, or the app's. */
repo: string
/** `app` seeds the app rules; `package` this package's inward set (UIG-5). */
audience?: 'app' | 'package'
/** The gate's config file, relative to `root`. */
config?: string
}
export interface CountResult extends GateCount {
changed: boolean
/** What to print, line by line. */
lines: string[]
/** What to print to stderr when a rule was switched off; empty when none was. */
failures: string[]
}
/** ESLint from where the gate lints, never from beside this file: an app's own install. */
async function eslintFrom(root: string): Promise {
const resolved = createRequire(join(root, 'package.json')).resolve('eslint')
return ((await import(pathToFileURL(resolved).href)) as { ESLint: typeof ESLintClass }).ESLint
}
export async function writeGateCount({ root = process.cwd(), repo, audience = 'app', config = 'eslint.gates.config.js' }: CountOptions): Promise {
const out = join(root, '.gates-count.json')
const ESLint = await eslintFrom(root)
const eslint = new ESLint({
cwd: root,
overrideConfigFile: join(root, config),
overrideConfig: { settings: { estiva: { reportEscapes: true } } },
})
// The set the gate runs, so a rule with nothing to report still has its row.
const { rules, disabled } = countGates(await eslint.lintFiles(['.']), audience === 'package' ? PACKAGE_RULE_IDS : APP_RULE_IDS)
const previous = existsSync(out) ? (JSON.parse(readFileSync(out, 'utf8')) as { rules?: unknown }) : null
const changed = JSON.stringify(previous?.rules) !== JSON.stringify(rules)
if (changed) {
const count = { schemaVersion: 1, repo, generatedAt: new Date().toISOString(), rules }
writeFileSync(out, `${JSON.stringify(count, null, 2)}\n`)
}
const lines = [changed ? '.gates-count.json: written' : '.gates-count.json: unchanged']
for (const [id, c] of Object.entries(rules)) lines.push(` ${id}: ${c.errors} errors, ${c.warnings} warnings, ${c.escapes} escapes`)
const failures: string[] = []
if (disabled.length > 0) {
failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead, naming the rule: // @estiva-escape(): ')
for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`)
}
return { rules, disabled, changed, lines, failures }
}