Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
430 lines
20 KiB
JavaScript
430 lines
20 KiB
JavaScript
/*
|
|
Have we dropped any of upstream's destinations?
|
|
|
|
The review plan said "open the same screen in `technitium-ui-ref` and compare by
|
|
eye". Things slip past the eye, and they did, big ones: the About panel had lost
|
|
eight of its nine links, and the whole `body` footer, with the author's support
|
|
and donation addresses inside it.
|
|
|
|
This does it by list, and without a browser: upstream's console is a single
|
|
`index.html` with ALL its panels in the markup, so its destinations are read from
|
|
the HTML; ours are read from the application code. No new dependencies (putting
|
|
Playwright in `package.json` would show up in the pull request diff), and
|
|
therefore runnable anywhere with the reference console up.
|
|
|
|
node dev/check-parity-controls.mjs
|
|
REF=http://other:5381 node dev/check-parity-controls.mjs
|
|
|
|
What it does NOT answer, worth having in front of you before believing a green:
|
|
|
|
- **It searches ALL the code, not the screen in question.** If an explanation
|
|
appears in two dialogs and falls out of one, this stays green. Verified: the
|
|
DNSKEY TTL text was deliberately removed from "Sign Zone" and it said nothing,
|
|
because the same text lives in "DNSSEC Properties". It bites on what exists in
|
|
only one place, which is how things actually get lost: the About panel lost
|
|
eight links and none of them was anywhere else.
|
|
- Whether a destination present in both points to the same thing from the
|
|
equivalent screen.
|
|
- Whether a button that exists in both does the same thing. For behaviour there
|
|
is `check-parity-actions.sh`, which compares server state.
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
const REF = process.env.REF ?? 'http://127.0.0.1:5381'
|
|
const SOURCE = new URL('../src/', import.meta.url).pathname
|
|
|
|
/* Destinations upstream has and that must NOT be here, with their reason. Every
|
|
line is a decision, not an oversight: if it is not justified, it is a finding. */
|
|
const EXCUSED = new Map([
|
|
// Today this list is deliberately empty: it is left written so that whoever
|
|
// adds an excuse has to reason it out here.
|
|
])
|
|
|
|
function files(dir) {
|
|
const out = []
|
|
for (const n of readdirSync(dir)) {
|
|
const p = join(dir, n)
|
|
if (statSync(p).isDirectory()) out.push(...files(p))
|
|
else if (/\.(tsx?|css)$/.test(n)) out.push(p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
const normalise = (u) => u.replace(/\/$/, '').replace(/^http:/, 'https:')
|
|
|
|
/** The four entities that appear in upstream's texts. */
|
|
const decode = (t) =>
|
|
t
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'|'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
|
|
/*
|
|
The source WITHOUT comments.
|
|
|
|
A comment is not interface: nobody reads it on screen. While they were in Spanish
|
|
it made no difference, because they could not match an English sentence from
|
|
upstream; once translated to English it stopped making no difference: a comment
|
|
quoting the help text it explains would make a REALLY missing help text match,
|
|
and this check would go green for the opposite reason to the one it exists for.
|
|
|
|
They are stripped before comparing. String literals in the code stay, since that
|
|
is exactly where the text being searched for lives.
|
|
*/
|
|
const withoutComments = (t) =>
|
|
t.replaceAll(/\/\*[\s\S]*?\*\//g, ' ').replaceAll(/^\s*\/\/[^\n]*/gm, ' ')
|
|
|
|
const ours = files(SOURCE)
|
|
.filter((f) => !/\.test\./.test(f))
|
|
.map((f) => withoutComments(readFileSync(f, 'utf8')))
|
|
.join('\n')
|
|
|
|
const html = await fetch(`${REF}/`).then((r) => {
|
|
if (!r.ok) throw new Error(`the reference console answers ${r.status} at ${REF}`)
|
|
return r.text()
|
|
})
|
|
|
|
/* Upstream's destinations, with the screen they appear on. */
|
|
const theirs = new Map()
|
|
for (const m of html.matchAll(/href="((?:https?:|mailto:)[^"]+)"/g)) {
|
|
const url = normalise(m[1])
|
|
if (!theirs.has(url)) theirs.set(url, [])
|
|
/* The nearest container with an id, searching backwards. It is a HINT, not a
|
|
truth: upstream's markup does not close a modal before the next one starts,
|
|
so it serves to go and find it, not to cite it. */
|
|
const before = html.slice(0, m.index)
|
|
const near = [...before.matchAll(/id="(modal\w+|\w*TabPane\w+|footer)"/g)].pop()
|
|
theirs.get(url).push(near ? near[1] : '?')
|
|
}
|
|
|
|
/*
|
|
The application's text, reduced to words.
|
|
|
|
Comparing markup does not work here. JSX splits sentences (`Add{' '}<code>!</code>
|
|
character`) and many explanations travel as an ATTRIBUTE (`help="The duration for
|
|
which…"`), so stripping the tags to clean up takes with it the very text you came
|
|
looking for. Both produced false positives: the first accused the ACL text of
|
|
being lost, the second three more that had been in place for months.
|
|
|
|
Reducing both sides to bare words (no punctuation, no case, no symbols) removes
|
|
all of that difference. It adds noise (class names, attributes), but noise can
|
|
only produce a false NEGATIVE across eight consecutive words, and that does not
|
|
happen.
|
|
*/
|
|
const asWords = (t) =>
|
|
' ' +
|
|
t
|
|
.toLowerCase()
|
|
.replace(/"|'|'/g, ' ')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim() +
|
|
' '
|
|
|
|
/* JSX writes upstream's inline markup as markup too (`bind to <code>[::]</code>
|
|
local`), and the tag names would sit between the words as `code code`. Only
|
|
the attribute-free inline tags are dropped: a tag WITH attributes may be
|
|
carrying the very `help="…"` being looked for. Links are the exception: their
|
|
attributes are addresses, never explanations. */
|
|
const prose = asWords(
|
|
ours
|
|
.replace(/<\/?(?:code|b|i|em|strong|kbd|br\s*\/?)>/g, ' ')
|
|
.replace(/<\/?(?:Link|External|a)\b[^>]*>/g, ' ')
|
|
.replace(/\{' '\}/g, ' ')
|
|
// A bare JSX expression (`{hosts.doh}`) is a value filled at runtime, not
|
|
// prose; its name would otherwise sit between the words. Upstream's side
|
|
// drops its runtime labels the same way (see NOTES).
|
|
.replace(/\{[A-Za-z_$][\w.$]*\}/g, ' ')
|
|
// So is a template literal (`{`proxy_set_header ${realIpHeader} $remote_addr;`}`),
|
|
// where upstream has a `<code id="lbl…">` its script fills.
|
|
.replace(/\{`[^`]*`\}/g, ' '),
|
|
)
|
|
|
|
const missing = []
|
|
for (const [url] of theirs) {
|
|
if (EXCUSED.has(url)) continue
|
|
// the destination is searched for as-is and without the trailing slash: in the
|
|
// code it may be wrapped by the formatter, but a URL is never split.
|
|
const raw = url.replace(/^https:/, 'http:')
|
|
if (ours.includes(url) || ours.includes(raw) || ours.includes(`${url}/`)) continue
|
|
missing.push(url)
|
|
}
|
|
|
|
const total = theirs.size
|
|
for (const url of missing) {
|
|
console.log(` MISSING ${url} (upstream, near: ${[...new Set(theirs.get(url))].join(', ')})`)
|
|
}
|
|
|
|
console.log(
|
|
missing.length === 0
|
|
? `DESTINATION PARITY: all ${total} of upstream's are in the new console.`
|
|
: `DESTINATION PARITY: ${missing.length} of ${total} missing.`,
|
|
)
|
|
|
|
/*
|
|
And the help texts. Upstream does not mark them with a class of its own: they are
|
|
`div`s carrying the Bootstrap grid offset (`col-sm-offset-N col-sm-M`), which
|
|
turns out to be a reliable signature because nothing else goes there.
|
|
|
|
They are compared by a run of words from the middle, not by the whole sentence:
|
|
the beginning and the end are what gets reworded most while laying out, and what
|
|
matters is whether the explanation is there or not.
|
|
*/
|
|
const HELP = [...html.matchAll(/<div class="col-sm-offset-\d+ col-sm-\d+"[^>]*>([\s\S]*?)<\/div>/g)]
|
|
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
|
|
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
|
|
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
|
|
compared. */
|
|
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
|
|
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/ /g, ' '))
|
|
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
|
|
.filter((t) => t.length > 40)
|
|
|
|
const missingHelp = HELP.filter((t) => {
|
|
const words = asWords(t).trim().split(' ')
|
|
if (words.length < 12) return false
|
|
// three runs spread out: if NONE is there, the explanation is not there
|
|
const runs = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)]
|
|
return !runs.some((p) => prose.includes(` ${p.join(' ')} `))
|
|
})
|
|
|
|
console.log('')
|
|
for (const t of missingHelp) console.log(` MISSING help: ${t.slice(0, 120)}…`)
|
|
console.log(
|
|
missingHelp.length === 0
|
|
? `HELP PARITY: all ${HELP.length} of upstream's texts are present.`
|
|
: `HELP PARITY: ${missingHelp.length} of ${HELP.length} missing.`,
|
|
)
|
|
|
|
/*
|
|
And each field's EXAMPLE, which is what upstream puts in `placeholder`.
|
|
|
|
Added after finding thirty-four of them lost, and some were not decoration: the
|
|
one on "Add Zone" read `example.com or 192.168.0.0/24 or 2001:db8::/64`, which is
|
|
how you discover that field takes a reverse zone in CIDR form, and the one on
|
|
"Certificate Association Data" carried the example hex and the PEM alternative.
|
|
Without them the field does not explain what it expects.
|
|
|
|
It compares by VALUE against the whole source, with the same limitation as the
|
|
other two checks and for the same reason: working out which component each field
|
|
ends up in would require understanding the JSX. That lets through the case where
|
|
the example is present but on another field (it happened with "confirm password",
|
|
which existed in "Add User" and was missing from "Change Password"), so a value
|
|
repeated across several fields has to be looked at by eye. What it does catch,
|
|
and what was needed, is the wholesale loss.
|
|
*/
|
|
const EXAMPLES = new Map()
|
|
for (const m of html.matchAll(/<(?:input|textarea)[^>]*>/g)) {
|
|
const ph = /placeholder="([^"]*)"/.exec(m[0])
|
|
if (!ph) continue
|
|
const id = /id="([^"]*)"/.exec(m[0])
|
|
/* Upstream's image was built on Windows and its multi-line examples carry
|
|
CRLF; our tree is LF. It is the same normalisation `dev/check-parity.sh`
|
|
does, and without it the four multi-line examples came out as lost over a
|
|
carriage return. */
|
|
const value = decode(ph[1]).replace(/\r\n/g, '\n')
|
|
if (value.trim().length < 1) continue
|
|
if (!EXAMPLES.has(value)) EXAMPLES.set(value, id ? id[1] : '?')
|
|
}
|
|
|
|
/* The forwarder example is rewritten by `zone.js:139-152` depending on the
|
|
protocol, and the same is done here in `forwarderExample`: the one in the HTML
|
|
is only the initial value. */
|
|
const DYNAMIC = new Set(['8.8.8.8'])
|
|
|
|
const missingExample = [...EXAMPLES].filter(
|
|
([value]) => !DYNAMIC.has(value) && !ours.includes(value.split('\n')[0]),
|
|
)
|
|
|
|
console.log('')
|
|
for (const [value, id] of missingExample) {
|
|
console.log(` MISSING example for ${id}: ${value.split('\n')[0].slice(0, 60)}`)
|
|
}
|
|
console.log(
|
|
missingExample.length === 0
|
|
? `EXAMPLE PARITY: all ${EXAMPLES.size} of upstream's are present.`
|
|
: `EXAMPLE PARITY: ${missingExample.length} of ${EXAMPLES.size} missing.`,
|
|
)
|
|
|
|
/*
|
|
The notes and the explanations that are NOT on the grid offset.
|
|
|
|
HELP above only reads `col-sm-offset-*` divs, and on 2026-09-30 it said "all 110
|
|
of upstream's texts are present" against a v15.5.1 whose new LDAP pane was not
|
|
here at all: its eleven "Note!"/"Warning!" paragraphs are `<p>`, and its field
|
|
explanations are `<div style="padding-top: 5px;">`. Neither shape was read. Both
|
|
are now, with the same three-runs comparison.
|
|
*/
|
|
const NOTES = [
|
|
...html.matchAll(/<p>([\s\S]*?)<\/p>/g),
|
|
...html.matchAll(/<div style="padding-top: 5px;[^"]*">([\s\S]*?)<\/div>/g),
|
|
// and the third shape, the Zone Options tabs': `<div><b>Note!</b> …</div>`
|
|
...html.matchAll(/<div>\s*(<b>(?:Note|Warning)!<\/b>[\s\S]*?)<\/div>/g),
|
|
]
|
|
/* A `<span id="lbl…">` or `<code id="lbl…">` inside a note is a placeholder
|
|
`loadDnsSettings` overwrites on every load (main.js:1369-1372: the DoH, DoT,
|
|
DoQ and DoH(S) addresses). Its initial text is not contract, so it is not
|
|
compared. */
|
|
.map((m) => m[1].replace(/<(span|code) id="lbl\w+">[\s\S]*?<\/\1>/g, ' '))
|
|
.map((t) => t.replace(/<[^>]*>/g, ' ').replace(/ /g, ' '))
|
|
.map((t) => decode(t).replace(/\s+/g, ' ').trim())
|
|
.filter((t, i, all) => t.length > 40 && all.indexOf(t) === i)
|
|
|
|
/* Texts upstream has and this console deliberately does not, with the reason.
|
|
Each entry is a run of words, matched whole. */
|
|
const EXCUSED_NOTES = [
|
|
// deviation 1 in CONVENTIONS.md: Amber is not offered. No note names it today;
|
|
// the entry stays so that one which does is excused for that reason and no other.
|
|
'amber theme',
|
|
]
|
|
|
|
/* A run only proves its own note when no other upstream note shares it. v15.6.1
|
|
split the LDAP server note into "On Windows, the domain name used for LDAP
|
|
Server above ..." and "On Linux, the domain name used for LDAP Server above
|
|
...", and the Linux one passed on the run it shares with the Windows one while
|
|
it was not here at all. */
|
|
const noteWords = NOTES.map((t) => asWords(t))
|
|
const distinctive = (run) => noteWords.filter((w) => w.includes(` ${run} `)).length === 1
|
|
|
|
const missingNotes = NOTES.filter((t) => {
|
|
const words = asWords(t).trim().split(' ')
|
|
if (words.length < 12) return false
|
|
if (EXCUSED_NOTES.some((e) => asWords(t).includes(` ${e} `) && words.length < 40)) return false
|
|
const all = [words.slice(2, 10), words.slice(6, 14), words.slice(-8)].map((p) => p.join(' '))
|
|
const runs = all.filter(distinctive)
|
|
/* And its last eight words, always. A run proves the note is there, not that it
|
|
is whole: v15.6.2 added "and require a manual resync" to the end of the SOA
|
|
serial warning, and every run of the old text still matched. */
|
|
const end = words.slice(-8).join(' ')
|
|
return !(runs.length ? runs : all).some((p) => prose.includes(` ${p} `)) || !prose.includes(` ${end} `)
|
|
})
|
|
|
|
console.log('')
|
|
for (const t of missingNotes) console.log(` MISSING note: ${t.slice(0, 120)}…`)
|
|
console.log(
|
|
missingNotes.length === 0
|
|
? `NOTE PARITY: all ${NOTES.length} of upstream's notes and explanations are present.`
|
|
: `NOTE PARITY: ${missingNotes.length} of ${NOTES.length} missing.`,
|
|
)
|
|
|
|
/*
|
|
And the short texts of a control: a radio's option, a modal's title, an entry of
|
|
the account menu.
|
|
|
|
None of the passes above reads them (they are neither links, nor help, nor
|
|
examples, nor notes), so a whole dialog could go missing and every one of them
|
|
stay green. It is exactly what happened to `Change Theme`: absent by decision
|
|
until 2026-10-01, and nothing here could have told whether it came back whole.
|
|
They are compared as whole runs of words, against our source without comments.
|
|
*/
|
|
const accountMenu = html.slice(html.indexOf('<ul class="dropdown-menu">'))
|
|
const CONTROL_TEXTS = [
|
|
...[...html.matchAll(/<input type="radio"[^>]*>\s*([^<]+?)\s*<\/label>/g)].map((m) => ['radio', m[1]]),
|
|
...[...html.matchAll(/<h4 class="modal-title"[^>]*>([^<]+)<\/h4>/g)].map((m) => ['modal title', m[1]]),
|
|
...[...accountMenu.slice(0, accountMenu.indexOf('</ul>')).matchAll(/>([^<>]+)<\/a>/g)].map((m) => ['account menu', m[1]]),
|
|
]
|
|
.map(([kind, t]) => [kind, decode(t).replace(/\s+/g, ' ').trim()])
|
|
.filter(([, t], i, all) => t.length > 0 && all.findIndex(([, u]) => u === t) === i)
|
|
|
|
/* Upstream's control texts this console deliberately does not have, with the reason. */
|
|
const EXCUSED_TEXTS = new Map([
|
|
['Amber Theme', 'deviation 1 in CONVENTIONS.md: Amber is not offered in Change Theme'],
|
|
])
|
|
|
|
const missingText = CONTROL_TEXTS.filter(([, t]) => !EXCUSED_TEXTS.has(t) && !prose.includes(asWords(t)))
|
|
|
|
console.log('')
|
|
for (const [kind, t] of missingText) console.log(` MISSING ${kind}: ${t}`)
|
|
for (const [t, why] of EXCUSED_TEXTS) console.log(` EXCUSED ${t} (${why})`)
|
|
console.log(
|
|
missingText.length === 0
|
|
? `CONTROL TEXT PARITY: all ${CONTROL_TEXTS.length - EXCUSED_TEXTS.size} of upstream's radio options, modal titles and account-menu entries are present; ${EXCUSED_TEXTS.size} excused (EXCUSED_TEXTS).`
|
|
: `CONTROL TEXT PARITY: ${missingText.length} of ${CONTROL_TEXTS.length} missing.`,
|
|
)
|
|
|
|
/*
|
|
And the other direction, which none of the checks above can see: a field this
|
|
console still draws after upstream REMOVED it. v15.5 dropped Auto Prefetch; its
|
|
two fields stayed here, still required, and Settings could not be saved at all
|
|
against the new server, with every parity check green, because every check
|
|
asked "is upstream's text here?" and never "is our text still upstream's?".
|
|
|
|
Every `label="…"` in the source must exist, word for word, somewhere in
|
|
upstream's page.
|
|
*/
|
|
/* Upstream builds part of its interface from JavaScript (row menus, the Apps
|
|
buttons, the DHCP and Logs tab strips), so its scripts are read too. */
|
|
const scripts = await Promise.all(
|
|
[...html.matchAll(/<script src="(js\/[^"?]+)/g)].map((m) =>
|
|
fetch(`${REF}/${m[1]}`).then((r) => (r.ok ? r.text() : '')),
|
|
),
|
|
)
|
|
const theirWords = asWords([html, decode(html), ...scripts].join(' '))
|
|
const OUR_LABELS = new Set()
|
|
/* Where each label is written, for the Blocking exception below. */
|
|
const LABEL_FILES = new Map()
|
|
for (const f of files(SOURCE).filter((f) => !/\.test\.|fixture/.test(f))) {
|
|
for (const m of withoutComments(readFileSync(f, 'utf8')).matchAll(/(?<![-\w])label="([^"{}]{3,})"/g)) {
|
|
OUR_LABELS.add(m[1])
|
|
LABEL_FILES.set(m[1], [...new Set([...(LABEL_FILES.get(m[1]) ?? []), f])])
|
|
}
|
|
}
|
|
/* Accessible names this console gives to controls upstream leaves unnamed: a
|
|
menu button's `aria-label` and a tab strip's. They are read by a screen
|
|
reader, never drawn, so they are not interface text upstream could own. */
|
|
const ACCESSIBLE_NAMES = new Set([
|
|
'Blocking options',
|
|
'Blocking sections',
|
|
'Zone actions',
|
|
'DNSSEC actions',
|
|
'DHCP sections',
|
|
'Logs sections',
|
|
'Settings sections',
|
|
])
|
|
/*
|
|
The Blocking section is the deliberate exception that adds a screen
|
|
(CONVENTIONS.md, "Deliberate deviations"), so it has words upstream does not.
|
|
They are named here one by one, each with where it is written and where its file
|
|
declares it as ours, and each passes ONLY while every file that writes it is
|
|
under `screens/blocking/`. Anywhere else in the console it is still a stray, and
|
|
a label that is not in this list is a stray even inside the section.
|
|
*/
|
|
const BLOCKING = join(SOURCE, 'screens/blocking/')
|
|
const BLOCKING_OWN_LABELS = new Map([
|
|
['Block List Domains', 'Overview figure, blocking/Overview.tsx:361; declared ours at Overview.tsx:35'],
|
|
['Your Rules', 'Overview figure, blocking/Overview.tsx:365; declared ours at Overview.tsx:35'],
|
|
['Tree', "the Tree view's Blocked/Allowed selector, blocking/Rules.tsx:329; declared ours at Rules.tsx:37-38"],
|
|
['More lists', "Quick Add's group of the console's own catalogue, blocking/BlockLists.tsx:508; declared ours at BlockLists.tsx:48-49"],
|
|
['Listed as', 'Check a domain, the list entry that matched, blocking/DomainCheck.tsx:107; declared ours at DomainCheck.tsx:15'],
|
|
['Rule', 'Check a domain, the rule that matched, blocking/DomainCheck.tsx:117; declared ours at DomainCheck.tsx:15'],
|
|
['Add to group', 'the group a new list goes into, blocking/BlockLists.tsx:666; declared ours at BlockLists.tsx:48'],
|
|
])
|
|
const onlyInBlocking = (label) => (LABEL_FILES.get(label) ?? []).every((f) => f.startsWith(BLOCKING))
|
|
|
|
const labels = [...OUR_LABELS]
|
|
const upstreamLabels = labels.filter((l) => theirWords.includes(asWords(l)))
|
|
const accessible = labels.filter((l) => !upstreamLabels.includes(l) && ACCESSIBLE_NAMES.has(l))
|
|
const blockingOwn = labels.filter(
|
|
(l) => !upstreamLabels.includes(l) && !accessible.includes(l) && BLOCKING_OWN_LABELS.has(l) && onlyInBlocking(l),
|
|
)
|
|
const strayLabels = labels.filter((l) => !upstreamLabels.includes(l) && !accessible.includes(l) && !blockingOwn.includes(l))
|
|
|
|
console.log('')
|
|
for (const l of blockingOwn) console.log(` BLOCKING OWN label: ${l} (${BLOCKING_OWN_LABELS.get(l)})`)
|
|
for (const l of strayLabels) console.log(` NOT UPSTREAM label: ${l}`)
|
|
console.log(
|
|
strayLabels.length === 0
|
|
? `LABEL PARITY: ${upstreamLabels.length} of our ${labels.length} labels exist upstream; ` +
|
|
`${accessible.length} are accessible names upstream leaves unnamed (ACCESSIBLE_NAMES) and ` +
|
|
`${blockingOwn.length} are the Blocking section's own (BLOCKING_OWN_LABELS).`
|
|
: `LABEL PARITY: ${strayLabels.length} of ${labels.length} labels are not upstream's.`,
|
|
)
|
|
|
|
process.exit(
|
|
missing.length + missingHelp.length + missingExample.length + missingNotes.length + missingText.length + strayLabels.length === 0
|
|
? 0
|
|
: 1,
|
|
)
|