Project 7000, 4700, 2000 and 700 each need their own clusters of about 100 groups, and the next one should be built by following a pattern rather than by copying this plugin. The document separates two halves that turn out to be independent: which GROUPS are in the list (library/lists/, shared, already built and gated by check-list-prop-unit) and which ZONES they are cut into (this plugin's data/clusters.json). A new list needs the second half only if it wants its own territories. Measured rather than described: 28 leaf zones from 29 clusters.json entries -- the two deaf buckets collapse into one leaf. Never count zones from the buckets; ask leafZones(buildLegendNodes(...)), which is the one answer poster, packet and legend all agree on. Counting by hand produced 27 once (forgetting deaf) and a delete of 30 files another time. 5 registry lists, all present, with the membership counts they actually have (unreached 2082, unengaged 1929, frontier 1745, frontier-500k 71). doxa has no membership file, correctly: it is the unfiltered default. 7 selector types, and which type a zone uses IS the statement of how it was zoned -- from wagf_block (pure geography) through to an explicit pg_slugs roster. The 12 pg_slugs zones are the honest case, not the lazy one: below country level there is no ADM1 field to select by, so a rule would be a fiction that looks principled. The deaf poster stays recorded as the known exception. It is global and non-geographic by construction, the Driver has not solved it and said so, and the pattern says that rather than inventing a zoning. check-list-pattern-doc.mjs re-derives every number from clusters.json, lists.json and the membership files and compares them to the document's own text. Neither side is hardcoded twice, so it fails in both directions: a doc that drifts, and data that moves while the doc sits still. Shown capable of failing, five ways: leaf count 28->27, a membership count, a selector count, and a real new zone (which trips three claims at once). The fifth is the useful one -- malformed data now exits 75, NOT MEASURED, because an uncaught throw exits 1 and would read exactly like the document being wrong. Found by mutation-testing; the first draft crashed with a stack trace I would have mistaken for a pass at catching drift. Also fixed in the harness: the registry-id parser matched any backticked lowercase table cell and read the selector table's `union` as an unknown list -- a false failure against a correct doc. Each table is now parsed from its own section.
163 lines
9.3 KiB
JavaScript
163 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-list-pattern-doc — the numbers in PATTERN-A-LIST-OF-ZONES.md must still be true.
|
|
*
|
|
* That document tells the next agent how to turn a project list (7000/4700/2000/700) into ~100
|
|
* zones, and it does it by QUOTING MEASUREMENTS: 28 leaf zones, 5 registry lists, 7 selector types,
|
|
* four membership files with specific slug counts. Prose like that is the first thing to rot — the
|
|
* data moves, the sentence does not, and a confident wrong number in a how-to is worse than no
|
|
* how-to, because the reader has no reason to doubt it.
|
|
*
|
|
* ★ IT RE-DERIVES EVERY NUMBER FROM THE SOURCE AND COMPARES TO THE DOC'S OWN TEXT. It does not
|
|
* assert the numbers are 28/5/7 — hardcoding the expected value on both sides is the tautology this
|
|
* whole class of gate falls into, where the check restates the doc instead of testing it. One side
|
|
* is parsed out of the markdown, the other is computed from clusters.json / lists.json / the
|
|
* membership files. They must agree.
|
|
*
|
|
* So this gate fails in BOTH useful directions:
|
|
* · someone adds a zone or a list and does not update the doc → mismatch
|
|
* · someone edits the doc's numbers without the data changing → mismatch
|
|
*
|
|
* ⚠ The deaf collapse (29 cluster entries → 28 leaves) is asserted explicitly, because it is the
|
|
* exact discrepancy that has already caused two real errors: a zone count of 27 that forgot `deaf`,
|
|
* and a delete of 30 files in the belief there were 28.
|
|
*
|
|
* Exit 0 pass · 1 fail · 75 NOT MEASURED.
|
|
*/
|
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
import { resolve, dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
const REPO = resolve(HERE, '..')
|
|
const PLUGIN = join(REPO, 'plugin-designs/my-upg-100-list')
|
|
const DOC = join(PLUGIN, 'documentation/PATTERN-A-LIST-OF-ZONES.md')
|
|
|
|
const fails = []
|
|
const notes = []
|
|
const ok = (label, got) => notes.push(` ok ${label.padEnd(58)} ${got}`)
|
|
const bad = (label, expected, got) => fails.push(`${label}\n doc says: ${expected}\n source is: ${got}`)
|
|
|
|
if (!existsSync(DOC)) {
|
|
console.error('LIST-PATTERN: NOT MEASURED (exit 75) — the document is absent; nothing to check.')
|
|
process.exit(75)
|
|
}
|
|
const doc = readFileSync(DOC, 'utf8')
|
|
|
|
// ── 1. leaf zones. The doc's headline count, and the one with a history of being wrong. ──────────
|
|
const clusters = JSON.parse(readFileSync(join(PLUGIN, 'data/clusters.json'), 'utf8'))
|
|
const tree = await import(join(PLUGIN, 'data/legend-tree.js'))
|
|
|
|
// ⚠ buildLegendNodes THROWS on a malformed zone (assertUniformLeafShape guards the consumer shape),
|
|
// and an uncaught throw here would exit 1 — the same code as a real FAIL, so "the doc is wrong" and
|
|
// "the data will not even load" would look identical to a reader and to CI. They need different
|
|
// answers: one is a doc to fix, the other is a broken clusters.json that this gate cannot speak to
|
|
// at all. Found by mutation-testing: adding a zone without its `count` field crashed the run with a
|
|
// stack trace and exit 1, which I would otherwise have read as the gate correctly failing.
|
|
let leaves
|
|
try {
|
|
leaves = tree.leafZones(tree.buildLegendNodes(clusters))
|
|
} catch (e) {
|
|
console.error('LIST-PATTERN: NOT MEASURED (exit 75) — clusters.json does not build a legend tree,')
|
|
console.error(' so nothing can be said about whether the document matches it. Fix the data first;')
|
|
console.error(' the zone-shape gate (assertUniformLeafShape) is the one describing the problem:')
|
|
console.error(` ${e.message.split('\n').slice(0, 3).join('\n ')}`)
|
|
process.exit(75)
|
|
}
|
|
|
|
const docLeaves = Number((doc.match(/⇒\s*(\d+)/) || [])[1])
|
|
if (docLeaves !== leaves.length) bad('leaf-zone count', docLeaves, leaves.length)
|
|
else ok('leaf zones, re-derived through leafZones()', leaves.length)
|
|
|
|
// The entry count, and that the difference is exactly the deaf collapse.
|
|
const entries = Object.values(clusters).filter(Array.isArray).flat().length
|
|
const docEntries = Number((doc.match(/(\d+)\s+entries,\s+\d+\s+zones/) || [])[1])
|
|
if (docEntries !== entries) bad('clusters.json entry count', docEntries, entries)
|
|
else ok('clusters.json list entries', entries)
|
|
|
|
const deafLeaves = leaves.filter((z) => /deaf/i.test(z.id || z.key || ''))
|
|
const deafEntries = (clusters.deaf_lists || []).length
|
|
if (deafLeaves.length !== 1) bad('the deaf collapse yields ONE leaf', 1, deafLeaves.length)
|
|
else if (entries - leaves.length !== deafEntries - 1) {
|
|
bad('entries minus leaves is explained by the deaf collapse alone', `${deafEntries - 1}`, `${entries - leaves.length}`)
|
|
} else ok('the 29→28 collapse is exactly the deaf buckets merging', `${deafEntries} entries → 1 leaf`)
|
|
|
|
// ── 2. the registry. Every list the doc tables must exist, with those aliases. ────────────────────
|
|
const registry = JSON.parse(readFileSync(join(REPO, 'library/lists/lists.json'), 'utf8'))
|
|
|
|
/**
|
|
* Slice the markdown to ONE table, by the heading it sits under.
|
|
* ⚠ NOT OPTIONAL. The first version of this gate matched `^| \`id\` |` across the WHOLE document
|
|
* and swept up the SELECTOR table too, reporting `union` as an unknown registry list — a false
|
|
* failure against a correct doc. Two tables in one file share a row shape; the only thing that
|
|
* distinguishes them is where they are.
|
|
*/
|
|
function section(fromHeading, toHeading) {
|
|
const a = doc.indexOf(fromHeading)
|
|
if (a < 0) return ''
|
|
const b = toHeading ? doc.indexOf(toHeading, a + 1) : -1
|
|
return doc.slice(a, b < 0 ? undefined : b)
|
|
}
|
|
const listSection = section('## Half 1', '## Half 2')
|
|
const zoneSection = section('## Half 2', '## What you get')
|
|
|
|
const docIds = [...listSection.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|/gm)].map((m) => m[1])
|
|
const realIds = registry.lists.map((l) => l.id)
|
|
const missing = realIds.filter((id) => !docIds.includes(id))
|
|
const extra = docIds.filter((id) => !realIds.includes(id))
|
|
if (missing.length) bad('every registry list appears in the doc table', `missing ${missing.join(', ')}`, realIds.join(', '))
|
|
else if (extra.length) bad('the doc table lists only real registry ids', `unknown ${extra.join(', ')}`, realIds.join(', '))
|
|
else ok('registry lists, all present in the doc table', realIds.length)
|
|
|
|
if (registry.default !== 'doxa') bad('the default list', 'doxa', registry.default)
|
|
else ok('the default list is the unfiltered one', registry.default)
|
|
|
|
// ── 3. selector types. The doc's table is the vocabulary the pattern hands the next author. ───────
|
|
const selCounts = {}
|
|
for (const v of Object.values(clusters)) {
|
|
if (!Array.isArray(v)) continue
|
|
for (const l of v) {
|
|
const t = l?.selector?.type
|
|
if (t) selCounts[t] = (selCounts[t] || 0) + 1
|
|
}
|
|
}
|
|
for (const [type, n] of Object.entries(selCounts)) {
|
|
const row = zoneSection.match(new RegExp(`^\\|\\s*\`${type}\`\\s*\\|\\s*(\\d+)\\s*\\|`, 'm'))
|
|
if (!row) bad(`selector type "${type}" has a row in the doc`, 'a row', `${n} zones use it, undocumented`)
|
|
else if (Number(row[1]) !== n) bad(`selector "${type}" zone count`, row[1], n)
|
|
}
|
|
const docSelTypes = [...zoneSection.matchAll(/^\|\s*`(\w+)`\s*\|\s*\d+\s*\|/gm)].map((m) => m[1])
|
|
.filter((t) => t in selCounts || /^(wagf_|country_|union|pg_slugs|deaf_)/.test(t))
|
|
for (const t of docSelTypes) {
|
|
if (!(t in selCounts)) bad(`doc documents selector "${t}"`, 'in use', 'no zone uses it')
|
|
}
|
|
if (!fails.length) ok('selector types and their zone counts', Object.keys(selCounts).length + ' types')
|
|
|
|
// ── 4. membership files. The doc quotes four slug counts; doxa must have none. ────────────────────
|
|
const MEM = join(REPO, 'public/dx-maps/assets/people-groups/lists')
|
|
if (!existsSync(MEM)) {
|
|
console.error('LIST-PATTERN: NOT MEASURED (exit 75) — membership directory absent, so the counts')
|
|
console.error(` in the doc cannot be checked either way: ${MEM}`)
|
|
process.exit(75)
|
|
}
|
|
for (const f of readdirSync(MEM).filter((f) => f.endsWith('.json'))) {
|
|
const id = f.replace(/\.json$/, '')
|
|
const n = (JSON.parse(readFileSync(join(MEM, f), 'utf8')).slugs || []).length
|
|
const row = listSection.match(new RegExp(`^${id}\\s+(\\d+)`, 'm'))
|
|
if (!row) bad(`membership "${id}" is quoted in the doc`, 'a row', `${n} slugs, undocumented`)
|
|
else if (Number(row[1]) !== n) bad(`membership "${id}" slug count`, row[1], n)
|
|
}
|
|
if (existsSync(join(MEM, 'doxa.json'))) {
|
|
bad('doxa has NO membership file (it is the unfiltered default)', 'absent', 'present')
|
|
} else ok('doxa has no membership file, as the default requires', 'absent')
|
|
|
|
// ── report ───────────────────────────────────────────────────────────────────────────────────────
|
|
for (const n of notes) console.log(n)
|
|
if (fails.length) {
|
|
console.error('LIST-PATTERN: FAIL — PATTERN-A-LIST-OF-ZONES.md disagrees with the data it describes.')
|
|
for (const f of fails) console.error(` ✗ ${f}`)
|
|
console.error('\n Fix whichever is wrong. If the DATA changed, update the doc; if the DOC drifted,')
|
|
console.error(' correct the number. Do not delete the claim to make this pass.')
|
|
process.exit(1)
|
|
}
|
|
console.log(`LIST-PATTERN: PASS — ${notes.length} claims in PATTERN-A-LIST-OF-ZONES.md re-derived from clusters.json, lists.json and the membership files.`)
|