Files
lage-chat-control/migration/compare.mjs
T

164 lines
5.8 KiB
JavaScript
Raw Normal View History

// QA gate for the content migration: proves that, inside the narrative window
// (everything between the first section head and the manifesto), the built
// pages carry EXACTLY the legacy prose, line-for-line and in order, modulo
// the documented categories below. Anything else fails loudly.
//
// Categories (each is reported with counts and samples):
// labels built-only lines that are ToolCard field labels — in the
// legacy page these strings were CSS-generated (::before
// content), so they are absent from the legacy DOM text.
// excluded legacy-only lines that belong to the deliberately deferred
// legislative-status claims (see extract-log.json).
// quotes " and ' are typeset as “ ” by Astro's default
// markdown `smartypants`; normalized before comparing.
// spacing identical text but different whitespace at tag boundaries
// (component templates put line breaks between inline elements
// where the legacy HTML had none).
//
// Usage: node migration/compare.mjs (after extract + build + extract --built)
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const INV = join(dirname(fileURLToPath(import.meta.url)), 'inventory')
const LANGS = ['en', 'fr', 'nl']
const LABELS = {
en: new Set(["What it's for", 'Why it matters', 'For whom & when', 'Install & use']),
fr: new Set(['À quoi ça sert', 'Pourquoi', 'Pour qui & quand', 'Installer & utiliser']),
nl: new Set([
'Waarvoor dient het',
'Waarom het ertoe doet',
'Voor wie & wanneer',
'Installeren & gebruiken',
]),
}
// The six cell lines per language of the two deferred #menace table rows.
const EXCLUDED = {
en: [
'Duration',
'Temporary, extended to 3 April 2028',
'Permanent',
'Status (Jul 2026)',
'Adopted 9 July 2026',
'Under negotiation, 5th trilogue failed 29 June',
],
fr: [
'Durée',
"Temporaire, prolongé jusqu'au 3 avril 2028",
'Permanent',
'Statut (juil. 2026)',
'Adopté le 9 juillet 2026',
'En négociation, 5e trilogue échoué le 29 juin',
],
nl: [
'Looptijd',
'Tijdelijk, verlengd tot 3 april 2028',
'Permanent',
'Status (juli 2026)',
'Aangenomen op 9 juli 2026',
'In onderhandeling, 5e trialoog mislukt op 29 juni',
],
}
const unquote = (s) => s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'")
const START = /^(PART|PARTIE|DEEL|MEMO) 00 · |^(PART|PARTIE|DEEL) 00 · /
const END = /(become ungovernable|devenir ingouvernable|onbestuurbaar worden)/i
function narrativeWindow(lines, file) {
const start = lines.findIndex((l) => START.test(l))
// the end sentinel is searched after the start (the new hero h1 also
// contains "Become Ungovernable")
const end = lines.findIndex((l, idx) => idx > start && END.test(l))
if (start === -1 || end === -1 || end < start) {
throw new Error(`cannot find narrative window in ${file} (start=${start}, end=${end})`)
}
return {
head: lines.slice(0, start),
body: lines.slice(start, end + 1),
tail: lines.slice(end + 1),
}
}
const report = {}
let failed = false
for (const lang of LANGS) {
const legacyAll = readFileSync(join(INV, `legacy-${lang}.txt`), 'utf8')
.trimEnd()
.split('\n')
const builtAll = readFileSync(join(INV, `built-${lang}.txt`), 'utf8')
.trimEnd()
.split('\n')
const legacy = narrativeWindow(legacyAll, `legacy-${lang}.txt`)
const built = narrativeWindow(builtAll, `built-${lang}.txt`)
const excludedPending = [...EXCLUDED[lang]]
const stats = { labels: 0, excluded: [], quotes: 0, spacing: [], unexplained: [] }
let i = 0
let j = 0
const L = legacy.body
const B = built.body.map(unquote)
const Braw = built.body
while (i < L.length || j < B.length) {
const a = L[i]
const b = B[j]
if (a !== undefined && b !== undefined && a === b) {
if (Braw[j] !== b) stats.quotes++
i += 1
j += 1
continue
}
if (a !== undefined && b !== undefined && a.replace(/\s+/g, '') === b.replace(/\s+/g, '')) {
if (Braw[j] !== b) stats.quotes++
stats.spacing.push(a.length > 90 ? a.slice(0, 90) + '…' : a)
i += 1
j += 1
continue
}
if (b !== undefined && LABELS[lang].has(b)) {
stats.labels++
j++
continue
}
if (a !== undefined && excludedPending.includes(a)) {
excludedPending.splice(excludedPending.indexOf(a), 1)
stats.excluded.push(a)
i++
continue
}
stats.unexplained.push({ line: i + 1, legacy: a ?? '(end)', built: b ?? '(end)' })
failed = true
if (stats.unexplained.length > 10) break
i += 1
j += 1
}
report[lang] = {
legacy: { chromeHead: legacy.head.length, narrative: L.length, chromeTail: legacy.tail.length },
built: { chromeHead: built.head.length, narrative: B.length, chromeTail: built.tail.length },
matchedAfterCategories: !stats.unexplained.length,
toolCardLabelLines: stats.labels,
deferredLegislativeLines: stats.excluded,
linesWithSmartQuotes: stats.quotes,
tagBoundarySpacingLines: stats.spacing.length,
tagBoundarySpacingSamples: stats.spacing.slice(0, 8),
unexplained: stats.unexplained,
}
}
writeFileSync(join(INV, 'compare-report.json'), JSON.stringify(report, null, 2) + '\n')
for (const [lang, r] of Object.entries(report)) {
console.log(
`${lang}: narrative legacy=${r.legacy.narrative} built=${r.built.narrative} | ` +
`labels=+${r.toolCardLabelLines} excluded=-${r.deferredLegislativeLines.length} ` +
`smartquotes=${r.linesWithSmartQuotes} spacing=${r.tagBoundarySpacingLines} ` +
`unexplained=${r.unexplained.length}`,
)
for (const u of r.unexplained) console.log(' MISMATCH', u)
}
process.exit(failed ? 1 : 0)