Keep rule exceptions visible to reviewers. Record the reason, owner, and expiry so exceptions can be checked later.
Add "Reviewed rule exceptions" from ntent to this repo.
Fetch https://ntent.app/r/f/waivers as plain text. Write it verbatim to scripts/lib/waivers.mjs.
It needs a checker of your own to import it. If this repo does not have it, stop and tell me.
Then check it: Waive a line with no reason and the run prints NO REASON GIVEN against it. Fix the underlying value and leave the waiver, and the next run names it as stale and tells you to delete it. Date one `until` yesterday and the finding comes back, naming the waiver that ran out.Reads https://ntent.app/r/f/waivers
Read the code
waivers.mjsscripts/lib/waivers.mjs
587 lines
/**
* Waivers: an exception a reviewer can still see three weeks later.
*
* Every checker in this repo eventually meets a line that is genuinely allowed
* to break its rule. There are three ways to handle that and only one of them
* survives contact with a real codebase.
*
* An inline eslint-disable. Invisible. Nothing prints it, nothing counts it,
* nothing notices when the rule it silences stopped applying two refactors
* ago. scripts/eslint-rules.mjs says never to reach for one and then, until
* this file, shipped no alternative.
*
* An ALLOWLIST at the top of the checker. Visible, but it names a symbol and
* not a place, so it forgives every occurrence including the ones nobody has
* written yet.
*
* A comment on the offending statement, collected and REPORTED on every run.
* That is this file.
*
* FOUR PROPERTIES, and the whole point is that all four hold at once.
*
* 1. A WAIVER COVERS A LOGICAL STATEMENT, NOT A PHYSICAL LINE. This is the
* subtle one. Anchor a waiver to line 40 and the next `prettier --write`
* rewraps that call across lines 40 to 42, the violation lands on line 41,
* and the waiver silently stops covering it. The checker then fails on a
* line somebody already justified, and the fix people reach for is a wider
* waiver. So a waiver covers the balanced bracket region its anchor belongs
* to: reflowing the code inside a statement does not change the statement.
*
* 2. THE REASON IS PRINTED, NOT JUST STORED. Every run lists each waived
* finding with its reason. A summary line saying "7 waived" is not a review;
* seven named lines with seven reasons is. This is the property that makes a
* waiver cheaper to delete than to keep.
*
* 3. STALE WAIVERS ARE NAMED. A waiver whose finding has gone away is reported
* and told to delete. Without this, waivers only ever accumulate, and an
* exception nobody can still justify becomes permanent within a month.
*
* 4. A WAIVER WITH NO REASON IS CALLED OUT. It prints as NO REASON GIVEN rather
* than being quietly accepted. A waiver with no reason is the violation with
* extra steps.
*
* 5. A WAIVER MAY CARRY AN EXPIRY, AND PAST IT THE WAIVER IS VOID. `until
* 2026-06-30` makes the exception temporary in a way the calendar enforces
* rather than a way somebody has to remember. Past its date the finding goes
* live again with a line saying which waiver expired, so the choice is to do
* the work or to agree a new date and write down what changed. Moving the
* date on its own is how an exception becomes permanent.
*
* The date is OPTIONAL here and required in a curated allowlist, and the
* difference is not an inconsistency. An allowlist entry has nothing that
* ever expires it, so the date is the only pressure on it. A waiver already
* has property 3: the day its finding goes away it is named and deleted. The
* date is for the waivers you already know are temporary — the vendor fix
* that is coming, the migration that is half done.
*
* THREE FORMS, all with the directive token your checker chooses:
*
* /* check-ignore -- third-party embed demands a literal *\/ on or above the statement
* // check-ignore-next-line -- ditto the statement below
* /* check-ignore-start *\/ ... /* check-ignore-end *\/ an explicit region
*
* Any of the three may carry an expiry, before the reason:
*
* /* check-ignore until 2026-06-30 -- vendor ships a token in v4 *\/
*
* WHAT THIS DOES NOT PROVE. It does not check that the reason is true, that it
* is still true, or that the waiver is the smallest one that would work. It
* proves only that somebody wrote a reason down in the place the next person
* will look, and that the run says so out loud. The review is still yours.
*
* Usage, which is the whole adoption cost:
*
* import { scanSource, collectWaivers, applyWaivers, reportWaivers } from './lib/waivers.mjs'
*
* const TOKEN = 'project-check-ignore'
* const live = [], waived = [], stale = [], expired = []
*
* for (const file of files) {
* const scan = scanSource(readFileSync(file, 'utf8'), { isCss: file.endsWith('.css') })
* const waivers = collectWaivers(scan, { token: TOKEN })
* const findings = myRules(scan) // [{ line, rule, text, hint }], line 0-based
* const split = applyWaivers(findings, waivers, file)
* live.push(...split.live); waived.push(...split.waived); stale.push(...split.stale)
* expired.push(...split.expired)
* }
*
* reportWaivers({ waived, stale, expired, token: TOKEN })
*
* Line numbers are 0-based everywhere in the API and printed 1-based. Findings
* need a `line`; `rule` and `text` are used in the report if present.
*/
// -------------------------------------------------------------- DEFAULTS ---
const DEFAULTS = {
// The directive each project spells its own way, so a checker's waivers are
// greppable and cannot be confused with another tool's.
token: 'check-ignore',
// A waiver covering more lines than this is a runaway, not an intention. When
// the span blows past it the waiver collapses back to its own line: an
// under-waived finding shows up as a failure you can see, an over-waived one
// fails silently. Bias to visible.
maxSpan: 40,
}
// ---------------------------------------------------------------- EXPIRY ---
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const isoToday = () => new Date().toISOString().slice(0, 10)
/**
* Read `until <date>` out of the text between the directive and the reason.
*
* Dates are compared as strings, which is why the format is enforced rather
* than parsed leniently. `until soon` would sort after every real date and the
* waiver would be permanent while looking dated, which is the exact failure the
* expiry exists to prevent — so anything that is not YYYY-MM-DD voids the
* waiver instead of being ignored. A malformed date is louder than no date.
*/
function readExpiry(head, today) {
const match = /\buntil\b\s*(\S*)/.exec(head.replace(/\*\/\s*$/, ''))
if (!match) return { until: null, void: null }
const until = match[1]
if (!ISO_DATE.test(until)) return { until: until || null, void: 'malformed' }
return { until, void: until < today ? 'expired' : null }
}
// ----------------------------------------------------------- SOURCE SCAN ---
/**
* Lex a file into the four views a checker needs. Doing this once and sharing
* it is not an optimisation, it is what stops the waiver scanner and the rules
* disagreeing about where a string ends.
*
* comments [{ start, end, text }] byte offsets, text without the delimiters
* strings [{ start, end, text }] the body of every quoted run
* masked source with comments AND string bodies blanked. Structure only,
* so bracket counting is not thrown by a `{` inside a string.
* code source with comments blanked and strings KEPT. Literals live
* here, which is where a raw '#ff4438' in a .tsx actually hides.
* lines per-line bracket arithmetic, see the statement section below
* offsets byte offset of the start of each line
* lineOf offset -> 0-based line index
*
* Template literals count as strings in .ts/.tsx and do not exist in .css,
* where a lone backtick is just a character and treating it as a delimiter
* swallows the rest of the file.
*/
export function scanSource(source, { isCss = false } = {}) {
const comments = []
const strings = []
const masked = source.split('')
const code = source.split('')
const blank = (chars, from, to) => {
for (let i = from; i < to; i++) if (chars[i] !== '\n') chars[i] = ' '
}
let i = 0
while (i < source.length) {
const c = source[i]
const next = source[i + 1]
if (c === '/' && next === '*') {
const close = source.indexOf('*/', i + 2)
const end = close === -1 ? source.length : close + 2
comments.push({ start: i, end, text: source.slice(i + 2, end - 2) })
blank(masked, i, end)
blank(code, i, end)
i = end
continue
}
// `//` inside a .css file is not a comment, it is most often the middle of
// a url(https://...). Only .ts/.tsx get line comments.
if (!isCss && c === '/' && next === '/') {
let end = source.indexOf('\n', i)
if (end === -1) end = source.length
comments.push({ start: i, end, text: source.slice(i + 2, end) })
blank(masked, i, end)
blank(code, i, end)
i = end
continue
}
if (c === '"' || c === "'" || (!isCss && c === '`')) {
let j = i + 1
while (j < source.length) {
if (source[j] === '\\') {
j += 2
continue
}
if (source[j] === c) break
// An unterminated quote should end at the newline rather than eat the
// file. An apostrophe in a comment is the common cause and comments are
// already gone by here, but a stray one in JSX text is not.
if (c !== '`' && source[j] === '\n') break
j++
}
const end = Math.min(j + 1, source.length)
strings.push({ start: i + 1, end: j, text: source.slice(i + 1, j) })
blank(masked, i + 1, j)
i = end
continue
}
i++
}
const maskedText = masked.join('')
const offsets = lineOffsets(source)
return {
source,
comments,
strings,
masked: maskedText,
code: code.join(''),
offsets,
lines: buildLines(source, maskedText),
lineOf: (offset) => lineOfOffset(offsets, offset),
}
}
function lineOffsets(source) {
const offsets = [0]
for (let i = 0; i < source.length; i++) if (source[i] === '\n') offsets.push(i + 1)
return offsets
}
function lineOfOffset(offsets, offset) {
let lo = 0
let hi = offsets.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (offsets[mid] <= offset) lo = mid
else hi = mid - 1
}
return lo
}
// ------------------------------------------------------------ STATEMENTS ---
//
// The unit a waiver covers. Everything here exists to make property 1 hold.
// A line ending in one of these has not finished saying what it was saying, so
// the statement continues even though the brackets balance.
const CONTINUES = /[([{,=:?+\-&|]$|&&$|\|\|$/
// JSX is not bracket-balanced, so a waiver above a <Chart ... /> that prettier
// split across four lines has to cover the whole opening tag.
//
// It must NOT cover the element's children. `<div className="...">` wrapping a
// page balances against its `</div>` hundreds of lines later, and a waiver that
// reaches all of it is blanket permission wearing the costume of a specific
// exception. So closing tags are deleted before counting: an opening tag opens,
// its own `>` closes it, and `</div>` is invisible.
const CLOSING_TAG = /<\/[A-Za-z][\w.:-]*\s*>/g
const TAG_OPEN = /<[A-Za-z]/g
// Not `=>`, not `->`, not `<>`, not `!=>`: those are operators, not tag ends.
const TAG_CLOSE = /(?<![=\-<>!])>/g
function buildLines(source, masked) {
const raw = source.split('\n')
const code = masked.split('\n')
const net = code.map((l) => {
let d = 0
for (const ch of l) {
if (ch === '(' || ch === '[' || ch === '{') d++
else if (ch === ')' || ch === ']' || ch === '}') d--
}
const tags = l.replace(CLOSING_TAG, ' ')
d += (tags.match(TAG_OPEN) ?? []).length
d -= (tags.match(TAG_CLOSE) ?? []).length
return d
})
return { raw, code, net, count: raw.length }
}
/**
* The balanced region an anchor line belongs to, as [startLine, endLine].
* This is what survives a reflow: splitting one statement across five lines
* changes which physical lines it occupies but not which region they form.
*/
function logicalSpan(lines, anchor, maxSpan) {
let start = anchor
let end = anchor
const balance = () => {
let sum = 0
for (let i = start; i <= end; i++) sum += lines.net[i]
return sum
}
for (let guard = 0; guard < 500; guard++) {
// Backwards ONLY when the region closes brackets it never opened, so its
// opener must be above. The tempting extra test, "the line above ends in a
// continuation character", is what makes this run away: it pulls in a
// `return (` whose unclosed paren then drags the forward scan to the end of
// the component, waiving a whole file from a comment about one colour.
if (start > 0 && balance() < 0) {
start--
continue
}
if (end < lines.count - 1 && (balance() > 0 || CONTINUES.test(lines.code[end].trimEnd()))) {
end++
continue
}
break
}
if (end - start + 1 > maxSpan) return [anchor, anchor]
return [start, end]
}
function nextCodeLine(lines, from) {
for (let i = from; i < lines.count; i++) if (lines.code[i].trim() !== '') return i
return null
}
function prevCodeLine(lines, from) {
for (let i = from; i >= 0; i--) if (lines.code[i].trim() !== '') return i
return null
}
// --------------------------------------------------------------- WAIVERS ---
/**
* Comment bodies are normalised before matching, so a directive that prettier
* rewrapped or that somebody wrote as a JSDoc block still reads as one line.
* Without this, a waiver reformatted into
*
* /**
* * check-ignore -- the vendor widget
* *\/
*
* stops matching and the reason it carried is lost with it.
*/
function normalise(text) {
return text
.replace(/^[ \t]*\*/gm, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Collect every waiver in one file.
*
* Takes the result of scanSource. Returns:
* waived Map<0-based line, record>, every line any waiver covers
* records every waiver found, in source order, each with `used: false`
*
* A record is { id, reason, until, void: reason, commentLine, used }. `reason`
* is null when the author wrote no `-- why`, which the report calls out rather
* than accepting. `until` is the expiry as written, or null. `void` is null
* while the waiver still forgives things, and otherwise says why it no longer
* does: 'expired' or 'malformed'.
*
* A void waiver forgives nothing. Its findings go live and the run fails on
* them, which is the point: an expiry that only printed a warning would be a
* date nobody has to meet.
*/
export function collectWaivers(
scan,
{ token = DEFAULTS.token, maxSpan = DEFAULTS.maxSpan, today = isoToday() } = {},
) {
const { comments, lines, offsets } = scan
const directive = new RegExp(`${escapeRegExp(token)}(-next-line|-start|-end)?`)
const lineOf = (offset) => lineOfOffset(offsets, offset)
const waived = new Map()
const records = []
let nextId = 0
// First waiver wins a contested line, so the tightest one gets the credit and
// an enclosing block does not steal the "used" flag from the specific waiver
// inside it, which would report the specific one as stale.
const waive = (a, b, rec) => {
for (let i = a; i <= b; i++) if (!waived.has(i)) waived.set(i, rec)
}
let blockStart = null
for (const comment of comments) {
const text = normalise(comment.text)
const match = directive.exec(text)
if (!match) continue
const kind = match[1] ?? ''
const tail = text.slice(match.index + match[0].length)
// The reason is whatever follows `--` or `:`. Both spellings, because
// people write both and a reason lost to punctuation is a reason lost.
const reasonMatch = /(?:--|:)\s*(.+)$/.exec(tail)
// The expiry is read only from the part BEFORE the reason separator, so a
// reason that says "until the vendor ships v4" is prose and not a date.
const separator = tail.search(/(?:--|:)/)
const { until, void: voided } = readExpiry(separator === -1 ? tail : tail.slice(0, separator), today)
const commentLine = lineOf(comment.start)
const endLine = lineOf(comment.end)
const rec = {
id: nextId++,
reason: reasonMatch ? reasonMatch[1].trim().replace(/\s*\*\/$/, '') : null,
until,
void: voided,
commentLine,
used: false,
}
// The closing half of a block is not a waiver in its own right. Recording
// it as one makes every correctly written block report a stale waiver on
// its -end line, and a report that cries stale on well-formed code is one
// people learn to scroll past.
if (kind === '-end') {
if (blockStart !== null) {
// A reason written on the -end half rather than the -start half is
// still a reason. Losing it to punctuation placement helps nobody, and
// the same goes for an expiry written on the closing half.
if (!blockStart.rec.reason && rec.reason) blockStart.rec.reason = rec.reason
if (!blockStart.rec.until && rec.until) {
blockStart.rec.until = rec.until
blockStart.rec.void = rec.void
}
waive(blockStart.line, endLine, blockStart.rec)
}
blockStart = null
continue
}
records.push(rec)
if (kind === '-start') {
blockStart = { line: commentLine, rec }
continue
}
// The comment's own lines, so a violation on the same line as the directive
// is covered whatever else happens below.
waive(commentLine, endLine, rec)
if (kind === '-next-line') {
const anchor = nextCodeLine(lines, endLine + 1)
if (anchor === null) continue
const [a, b] = logicalSpan(lines, anchor, maxSpan)
waive(a, b, rec)
continue
}
// A bare directive covers the statement it sits on. When it sits on a line
// of its own it covers the statement on EITHER side, because that is what a
// formatter does to a trailing comment. Prettier will lift
//
// <Embed brandColor="#0000ff" /> {/* check-ignore -- vendor *\/}
//
// onto its own following line, and a strictly-downward reading then
// un-waives the code above it for a change nobody made. Reach for
// -next-line or -start/-end when the extra reach matters; a bare waiver is
// a deliberate act either way.
//
// A lone `{` is not code: `{/* ... *\/}` is only how JSX spells a comment.
const before = lines.code[commentLine]
.slice(0, comment.start - offsets[commentLine])
.trim()
.replace(/^\{+$/, '')
if (before !== '') {
const [a, b] = logicalSpan(lines, commentLine, maxSpan)
waive(a, b, rec)
continue
}
for (const anchor of [prevCodeLine(lines, commentLine - 1), nextCodeLine(lines, endLine + 1)]) {
if (anchor === null) continue
const [a, b] = logicalSpan(lines, anchor, maxSpan)
waive(a, b, rec)
}
}
// An unclosed -start block runs to the end of the file. It is not an error:
// the report will call it stale if it forgave nothing, which is the honest
// outcome for a block somebody opened and forgot.
if (blockStart !== null) waive(blockStart.line, lines.count - 1, blockStart.rec)
return { waived, records }
}
/**
* Split one file's findings into what still counts and what was forgiven, and
* work out which waivers forgave nothing.
*
* findings [{ line, rule, text, ... }] with 0-based `line`
* returns { live, waived, stale, expired }, all carrying `file` for the report
*
* A finding under a void waiver goes into BOTH `live` and `expired`: live so the
* run fails on it the way it would with no waiver at all, expired so the report
* can say which waiver ran out rather than leaving somebody to wonder why a line
* they thought was settled came back.
*/
export function applyWaivers(findings, { waived, records }, file = null) {
const live = []
const forgiven = []
const expired = []
for (const finding of findings) {
const rec = waived.get(finding.line)
if (!rec) {
live.push(file === null ? finding : { file, ...finding })
continue
}
// A void waiver is still a used one. Reporting it as stale as well would
// tell somebody to delete the waiver on a line that still breaks the rule.
rec.used = true
if (rec.void) {
live.push(file === null ? finding : { file, ...finding })
expired.push({
file,
line: rec.commentLine,
reason: rec.reason,
until: rec.until,
void: rec.void,
finding: finding.line,
})
continue
}
forgiven.push({ file, reason: rec.reason, until: rec.until, ...finding })
}
const stale = records
.filter((rec) => !rec.used)
.map((rec) => ({ file, line: rec.commentLine, reason: rec.reason }))
return { live, waived: forgiven, stale, expired }
}
// ---------------------------------------------------------------- REPORT ---
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
const order = (a, b) => String(a.file).localeCompare(String(b.file)) || a.line - b.line
/**
* Print what was forgiven, where, and why, then what can be deleted. Call this
* on a clean run as well as a failing one: properties 2 and 3 are worth nothing
* if the only time anybody sees them is when the build is already broken.
*/
export function reportWaivers({
waived = [],
stale = [],
expired = [],
token = DEFAULTS.token,
log = console.log,
} = {}) {
if (expired.length) {
log(`\n ${plural(expired.length, 'waiver')} no longer forgiving anything:`)
for (const e of [...expired].sort(order)) {
const what =
e.void === 'malformed'
? `until "${e.until ?? ''}" is not YYYY-MM-DD, so this waiver never had a date it could meet`
: `expired ${e.until}`
log(` ${e.file ?? '(source)'}:${e.line + 1} ${what}`)
if (e.reason) log(` it said: ${e.reason}`)
}
log(
' The finding is live again. Do the work, or agree a new date and write down what\n' +
' changed. Moving the date on its own is how an exception becomes permanent.',
)
}
if (waived.length) {
log(`\n ${plural(waived.length, 'finding')} waived by comment:`)
let current = null
for (const w of [...waived].sort(order)) {
const where = w.file ?? '(source)'
if (where !== current) {
current = where
log(` ${where}`)
}
const rule = w.rule ? String(w.rule).padEnd(13) : ''
log(` ${String(w.line + 1).padStart(5)}: ${rule} ${w.text ?? ''}`)
log(
` ${' '.repeat(rule.length)} ` +
(w.reason ? `reason: ${w.reason}` : `NO REASON GIVEN, add \`-- why\` to the ${token}`) +
(w.until ? ` (until ${w.until})` : ''),
)
}
}
if (stale.length) {
log(
`\n ${plural(stale.length, 'stale waiver')}, nothing on the waived statement breaks any rule any more:`,
)
for (const s of [...stale].sort(order)) {
log(` ${s.file ?? '(source)'}:${s.line + 1}${s.reason ? ` (${s.reason})` : ''}`)
}
log(' Delete them. Stale waivers are how a specific exception turns into blanket permission.')
}
}
Success check: Waive a line with no reason and the run prints NO REASON GIVEN against it. Fix the underlying value and leave the waiver, and the next run names it as stale and tells you to delete it. Date one `until` yesterday and the finding comes back, naming the waiver that ran out.