!
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 [::]
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 `` 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(/]*>([\s\S]*?)<\/div>/g)]
/* A `` or `` 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 ``, and its field
explanations are `
`. Neither shape was read. Both
are now, with the same three-runs comparison.
*/
const NOTES = [
...html.matchAll(/([\s\S]*?)<\/p>/g),
...html.matchAll(/
([\s\S]*?)<\/div>/g),
// and the third shape, the Zone Options tabs': `Note! …`
...html.matchAll(/\s*((?:Note|Warning)!<\/b>[\s\S]*?)<\/div>/g),
]
/* A `` or `` 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('')).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(/