Any group set can generate packets from one folder
The generator could already serve a second group set -- series.json's country series proves it -- but only one wired into series.json, reading upg100's bucketed clusters, upg100's legend tree for enumeration, and the shared records cache. A fork had no way in. The contract is now one folder. DATA_DIR points at it; clusters.json with a flat lists[] is the group set; records.json in the same folder means no API and no cache; selector-evaluator.js resolves from there, as it always did. normalize.js and legend-tree.js become optional -- absent, records are used as-is and the flat file IS the enumeration -- and the absence is logged rather than silent, because a silent fallback would let a typo'd normalize.js skip normalization on the real series. PACKET_OUT keeps a fork's output away from the real artifact directory, where toy packets would be counted as coverage. unlisted: true carries upg100's reachability idea across as data: the list is defined and selectable with ONLY=, and not generated by default. The bucketed shape keeps its legend-tree enumeration untouched, so the deaf collapse and the 28-zone arithmetic are byte-for-byte unchanged. The smoke test was written first and failed first, on all four counts. Its fixture is synthetic -- every slug and number invented -- and it asserts the unlisted list does NOT generate, which is as load-bearing as the two that do: an unlisted entry that generates anyway is the reachability bug inverted. One bug found in my own work while proving it: allZones became async, and the funnel's listCount read it from inside a try/catch that returns null on error -- so a use-before-init did not crash, it silently deleted the triangle from all 28 packets. Caught because a probe for the tip's "1 of 28" found nothing, not because anything failed. Resolved once, before the funnel is built; regenerated and verified -- funnel present, tip reads 1 of 28, 95 photos, 11 sheets.
This commit is contained in:
parent
32e7e4c30c
commit
af8da2259c
6 changed files with 222 additions and 9 deletions
81
internal/check-group-set-standard.mjs
Normal file
81
internal/check-group-set-standard.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-group-set-standard — a FORK's group set generates packets end-to-end.
|
||||
*
|
||||
* THE CONTRACT UNDER TEST (documentation/STANDARD-GROUP-SET.md in the plugin): a fork supplies ONE
|
||||
* FOLDER — clusters.json with a flat lists[] ({ id, label, selector, unlisted? }), records.json
|
||||
* (its own people rows, so no API), selector-evaluator.js (resolvable from the folder) — and points
|
||||
* the packet generator at it with DATA_DIR= and PACKET_OUT=. Nothing else. If that sentence stops
|
||||
* being true, this gate goes red before a fork finds out.
|
||||
*
|
||||
* ★ THE FIXTURE IS THE STANDARD'S FALSIFIER, deliberately synthetic (every slug and number is
|
||||
* invented — no real group data ships in a test fixture). It exercises exactly the generic shape:
|
||||
* FLAT lists[] (not upg100's bucketed one), an unlisted:true entry that must NOT generate, records
|
||||
* from the folder, and no posters directory — the map page must degrade to a cover, not kill the
|
||||
* run, because a fork's first run will not have posters yet.
|
||||
*
|
||||
* Written RED-FIRST: on the tree before the standard landed, packet.mjs knew only series.json
|
||||
* dataDirs, bucketed clusters, cached/API records and a fixed out/ — this test failed on all four.
|
||||
*
|
||||
* Headless, no network (NOPHOTOS + folder records). Exit 0 pass · 1 fail.
|
||||
*/
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { existsSync, readFileSync, readdirSync, rmSync, mkdtempSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const FIXTURE = join(HERE, 'fixtures', 'toy-group-set')
|
||||
const GEN = join(REPO, 'library', 'scripts', 'posters', 'packet.mjs')
|
||||
|
||||
const fails = []
|
||||
const ok = (c, m) => { if (!c) fails.push(m) }
|
||||
|
||||
// ⚠ OUTPUT GOES TO A TEMP DIR, NEVER THE REAL out/packets — toy packets landing beside the real 28
|
||||
// would make check:packets-all count fiction as coverage.
|
||||
const OUT = mkdtempSync(join(tmpdir(), 'toy-packets-'))
|
||||
const env = {
|
||||
...process.env,
|
||||
DATA_DIR: FIXTURE,
|
||||
PACKET_OUT: OUT,
|
||||
NOPHOTOS: '1',
|
||||
OFFLINE: '1',
|
||||
}
|
||||
const run = spawnSync('node', [GEN], { env, encoding: 'utf8', timeout: 120000 })
|
||||
const outText = String(run.stdout || '') + String(run.stderr || '')
|
||||
|
||||
ok(run.status === 0,
|
||||
`the generator exited ${run.status} on the toy dataDir — a fork cannot generate at all.\n tail: ${outText.trim().split('\n').slice(-4).join('\n ')}`)
|
||||
|
||||
if (run.status === 0) {
|
||||
const files = existsSync(OUT) ? readdirSync(OUT).filter((f) => f.endsWith('.html')).sort() : []
|
||||
// toy-hidden is unlisted:true — TWO packets, and the third's absence is as load-bearing as the
|
||||
// two's presence: an unlisted list that generates anyway is the reachability bug inverted.
|
||||
ok(files.includes('toy-north.html') && files.includes('toy-south.html'),
|
||||
`expected toy-north + toy-south packets, got: ${files.join(', ') || '(none)'}`)
|
||||
ok(!files.some((f) => f.startsWith('toy-hidden')),
|
||||
'the unlisted:true list generated a packet — unlisted must mean unlisted')
|
||||
|
||||
for (const f of ['toy-north.html', 'toy-south.html']) {
|
||||
if (!files.includes(f)) continue
|
||||
const html = readFileSync(join(OUT, f), 'utf8')
|
||||
const groups = (html.match(/class="card"/g) || []).length
|
||||
const want = f === 'toy-north.html' ? 3 : 2
|
||||
ok(groups === want, `${f}: ${groups} group rows, expected ${want} — the folder's records.json did not drive the join`)
|
||||
// The synthetic names must actually print — a packet of the right COUNT with the wrong rows
|
||||
// would pass a bare count.
|
||||
ok(f !== 'toy-north.html' || /Alpha People/.test(html), 'toy-north is missing its own synthetic people')
|
||||
// No posters exist for the toy: the map page must be the documented cover, and the run must
|
||||
// not have died for it (already asserted by exit 0).
|
||||
}
|
||||
}
|
||||
rmSync(OUT, { recursive: true, force: true })
|
||||
|
||||
if (fails.length) {
|
||||
console.error('GROUP-SET-STANDARD: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('GROUP-SET-STANDARD: PASS — a one-folder synthetic fork generates exactly its listed packets (and not its unlisted one) from its own records, with no API, no posters and no series.json edit.')
|
||||
9
internal/fixtures/toy-group-set/clusters.json
Normal file
9
internal/fixtures/toy-group-set/clusters.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"_synthetic": "Toy group set for check:group-set-standard. Every slug, name and number is invented — no real people-group data. The FLAT lists[] shape is the fork contract; unlisted:true keeps an entry out of generation.",
|
||||
"source_total": 8,
|
||||
"lists": [
|
||||
{ "id": "toy-north", "label": "Toy North", "selector": { "type": "pg_slugs", "values": ["toy-alpha", "toy-bravo", "toy-charlie"] } },
|
||||
{ "id": "toy-south", "label": "Toy South", "selector": { "type": "pg_slugs", "values": ["toy-delta", "toy-echo"] } },
|
||||
{ "id": "toy-hidden", "label": "Toy Hidden", "unlisted": true, "selector": { "type": "pg_slugs", "values": ["toy-foxtrot"] } }
|
||||
]
|
||||
}
|
||||
8
internal/fixtures/toy-group-set/records.json
Normal file
8
internal/fixtures/toy-group-set/records.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{ "slug": "toy-alpha", "name": "Alpha People", "country_code": "AAA", "country_code_label": "Toyland", "population": 1000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Toyish", "imb_gsec": 1 },
|
||||
{ "slug": "toy-bravo", "name": "Bravo People", "country_code": "AAA", "country_code_label": "Toyland", "population": 2000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Toyish", "imb_gsec": 2 },
|
||||
{ "slug": "toy-charlie", "name": "Charlie People", "country_code": "BBB", "country_code_label": "Boxland", "population": 3000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Boxish", "imb_gsec": 0 },
|
||||
{ "slug": "toy-delta", "name": "Delta People", "country_code": "BBB", "country_code_label": "Boxland", "population": 4000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Boxish", "imb_gsec": 4 },
|
||||
{ "slug": "toy-echo", "name": "Echo People", "country_code": "AAA", "country_code_label": "Toyland", "population": 5000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Toyish", "imb_gsec": 6 },
|
||||
{ "slug": "toy-foxtrot", "name": "Foxtrot People", "country_code": "AAA", "country_code_label": "Toyland", "population": 6000, "religion": "SYN", "religion_label": "Synthetic", "primary_language_label": "Toyish", "imb_gsec": 3 }
|
||||
]
|
||||
4
internal/fixtures/toy-group-set/selector-evaluator.js
Normal file
4
internal/fixtures/toy-group-set/selector-evaluator.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// The toy dataDir carries its own evaluator per the contract. Re-exported rather than copied:
|
||||
// the CONTRACT is "the folder resolves selector-evaluator.js"; the toy proves resolution, and a
|
||||
// copy here would be a second evaluator that drifts (the exact defect class the standard avoids).
|
||||
export { evaluate } from '../../../plugin-designs/my-upg-100-list/data/selector-evaluator.js'
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = join(__dirname, '../../..');
|
||||
|
|
@ -60,8 +60,18 @@ if (!SERIES_ALL[SERIES_ID] || SERIES_ID.startsWith('_')) {
|
|||
const SERIES = SERIES_ALL[SERIES_ID];
|
||||
// The data folder is a SERIES property, not a constant. It was hard-wired to my-upg-100-list,
|
||||
// which is the other half of why a country run could only ever be a UPG-100 run.
|
||||
const DATA = join(REPO, SERIES.dataDir);
|
||||
const OUT = join(__dirname, 'out', 'packets');
|
||||
// ★ DATA_DIR overrides the series folder entirely — the FORK CONTRACT (see the plugin's
|
||||
// documentation/STANDARD-GROUP-SET.md): one folder with clusters.json + records.json +
|
||||
// selector-evaluator.js generates packets with NO series.json edit. Series flags (funnel,
|
||||
// strings, countryMode) still come from SERIES_ID, defaulting to upg100's.
|
||||
const DATA = process.env.DATA_DIR ? resolve(process.env.DATA_DIR) : join(REPO, SERIES.dataDir);
|
||||
if (process.env.DATA_DIR && !existsSync(join(DATA, 'clusters.json'))) {
|
||||
console.error(`[packet] DATA_DIR set but ${join(DATA, 'clusters.json')} does not exist.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// PACKET_OUT keeps a fork's (or a test's) output out of the real artifact directory — toy packets
|
||||
// beside the real ones would make check:packets-all count fiction as coverage.
|
||||
const OUT = process.env.PACKET_OUT ? resolve(process.env.PACKET_OUT) : join(__dirname, 'out', 'packets');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
// ⚠ NOT process.env.LANG — that is the SHELL's POSIX locale ("en_US.UTF-8"), which
|
||||
|
|
@ -84,7 +94,8 @@ const API = process.env.VITE_API_BASE_URL || process.env.MAP_APP_API_URL;
|
|||
// the program refuses. Any env var that makes the API unnecessary must be named HERE, next to the
|
||||
// guard, or it is decoration.
|
||||
const CACHE_OK = process.env.OFFLINE && existsSync(join(__dirname, 'out', '_records.json'));
|
||||
if (!API && !process.env.PACKET_RECORDS && !CACHE_OK) {
|
||||
const DATA_RECORDS_EARLY = process.env.DATA_DIR && existsSync(join(resolve(process.env.DATA_DIR), 'records.json'));
|
||||
if (!API && !process.env.PACKET_RECORDS && !CACHE_OK && !DATA_RECORDS_EARLY) {
|
||||
console.error('[packet] No API base — set VITE_API_BASE_URL, or PACKET_RECORDS for a fixture build,');
|
||||
console.error(' or OFFLINE=1 to reuse out/_records.json if a previous run cached it.');
|
||||
if (process.env.OFFLINE) {
|
||||
|
|
@ -226,16 +237,31 @@ function buildFunnel(membership) {
|
|||
// ("13 + 12 + 2 + 1 deaf = 28"). The packet's triangle tip prints this number as "1 of N", so a
|
||||
// 27 there is a visible wrong fact on the page. Same omission as allZones() had before 3ef164c0;
|
||||
// this was the last copy of it.
|
||||
const listCount = allZones().length;
|
||||
const listCount = ZONES_ALL.length;
|
||||
const listTotal = Number(raw.steps[raw.steps.length - 1].groups) || 0;
|
||||
return { ...raw, listCount, listTotal };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
const { evaluate } = await import(join(DATA, 'selector-evaluator.js'));
|
||||
const { normalizeRecords, extractRecords } = await import(join(DATA, 'normalize.js'));
|
||||
// normalize.js is OPTIONAL in the fork contract: a fork's records.json is its own shape, already
|
||||
// flat. Absent -> identity, said OUT LOUD once — a silent fallback would let a TYPO'd normalize.js
|
||||
// quietly skip normalization on the real series, which is the fail-open trap.
|
||||
const { normalizeRecords, extractRecords } = await (async () => {
|
||||
try { return await import(join(DATA, 'normalize.js')); }
|
||||
catch {
|
||||
console.log('[packet] no normalize.js in the data folder — records are used as-is (fork contract).');
|
||||
return { normalizeRecords: (r) => r, extractRecords: (r) => (Array.isArray(r) ? r : (r?.posts || r?.records || [])) };
|
||||
}
|
||||
})();
|
||||
// The zone enumeration itself, so this generator, the poster generator and upg-100-list.vue cannot
|
||||
// disagree about which zones exist (see allZones below).
|
||||
const { buildLegendNodes, leafZones } = await import(join(DATA, 'legend-tree.js'));
|
||||
// legend-tree.js is loaded LAZILY, only for the bucketed (upg100) shape — a flat fork's file IS
|
||||
// its enumeration and must not be forced to ship a legend module it never uses.
|
||||
let _legend = null;
|
||||
async function legendModules() {
|
||||
if (!_legend) _legend = await import(join(DATA, 'legend-tree.js'));
|
||||
return _legend;
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
// ★ A COMMITTED FIXTURE, so CI can build a packet at all. out/ is gitignored — both the
|
||||
|
|
@ -253,6 +279,10 @@ async function loadRecords() {
|
|||
if (!existsSync(fixture)) throw new Error(`PACKET_RECORDS set but missing: ${fixture}`);
|
||||
return JSON.parse(readFileSync(fixture, 'utf8'));
|
||||
}
|
||||
// The FORK CONTRACT's records: a dataDir may carry its own rows, so a fork needs no API and no
|
||||
// cache. Detected, not configured — the folder IS the interface.
|
||||
const folderRecords = join(DATA, 'records.json');
|
||||
if (existsSync(folderRecords)) return JSON.parse(readFileSync(folderRecords, 'utf8'));
|
||||
const cache = join(__dirname, 'out', '_records.json');
|
||||
if (process.env.OFFLINE && existsSync(cache)) return JSON.parse(readFileSync(cache, 'utf8'));
|
||||
// ★ THE FIELD LIST IS PART OF THE SELECTOR CONTRACT — every field a selector READS must be asked
|
||||
|
|
@ -310,7 +340,7 @@ async function loadRecords() {
|
|||
//
|
||||
// leafZones() carries id/zoneKey/label/count but NOT selector (leaves do not), so the selector is
|
||||
// re-attached by id exactly as generate.mjs:2801-2808 does it, deaf included.
|
||||
function allZones() {
|
||||
async function allZones() {
|
||||
// ★ COUNTRY MODE HAS ITS OWN ZONE SOURCE, and leaving it out broke the packet half of
|
||||
// SERIES=country for every country. Measured by exp-shepherd on main, 2026-09-28:
|
||||
// `SERIES=country ONLY=country-yem node generate.mjs` made a poster, and the matching
|
||||
|
|
@ -348,6 +378,18 @@ function allZones() {
|
|||
selector: { type: 'country_codes', values: [c.iso3] },
|
||||
}));
|
||||
}
|
||||
// ★ THE FORK CONTRACT'S GENERIC SHAPE: a FLAT lists[] with an optional unlisted:true per entry.
|
||||
// Detected by the ABSENCE of upg100's extra buckets, so the 28-zone catalogue keeps its legend-
|
||||
// tree enumeration (the deaf collapse and its reachability semantics) byte-for-byte, and a fork
|
||||
// never needs a legend tree at all — its file IS the enumeration. unlisted:true is the same
|
||||
// reachability idea as data: defined, selectable by ONLY=, but not generated by default.
|
||||
const BUCKETED = !!(clusters.asia_lists || clusters.uncovered_lists || clusters.deaf_lists);
|
||||
if (!BUCKETED) {
|
||||
return (clusters.lists || [])
|
||||
.filter((l) => !l.unlisted || (process.env.ONLY && process.env.ONLY === l.id))
|
||||
.map((l) => ({ id: l.id, zoneKey: l.id, label: l.label || l.id, count: l.count, selector: l.selector }));
|
||||
}
|
||||
const { buildLegendNodes, leafZones } = await legendModules();
|
||||
const tops = buildLegendNodes(clusters, { records: null });
|
||||
const byId = {};
|
||||
for (const k of ['lists', 'asia_lists', 'uncovered_lists']) {
|
||||
|
|
@ -456,9 +498,15 @@ if (LIST_MEMBERSHIP) {
|
|||
console.log(`[packet] list=${LIST} — ${records.length} of ${allRecords.length} record(s) `
|
||||
+ `belong to it (list total ${LIST_MEMBERSHIP.total})`);
|
||||
}
|
||||
// Resolved ONCE, and it MUST precede FUNNEL: packetFunnel reads ZONES_ALL.length inside a
|
||||
// try/catch whose catch returns null — so a use-before-init here does not crash, it silently
|
||||
// deletes the funnel page from every packet. That exact failure shipped for one run: the tip
|
||||
// probe found no "1 of 28" anywhere, which is how a swallowed TDZ was caught as missing CONTENT
|
||||
// rather than as an error.
|
||||
const ZONES_ALL = await allZones();
|
||||
const FUNNEL = buildFunnel(LIST_MEMBERSHIP);
|
||||
const bySlug = new Map(records.map((r) => [r.slug, r]));
|
||||
const zones = allZones().filter((z) => !ONLY || z.id === ONLY);
|
||||
const zones = ZONES_ALL.filter((z) => !ONLY || z.id === ONLY);
|
||||
if (!zones.length) { console.error(`[packet] no zone matches ONLY=${ONLY}`); process.exit(1); }
|
||||
|
||||
let made = 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
# STANDARD — generate packets for ANY group set (the fork contract)
|
||||
|
||||
The UPG-100 machinery is generative: point the packet generator at **one folder** and it produces
|
||||
a packet per list, localized, with no code change and no `series.json` edit. This document is the
|
||||
contract; `internal/check-group-set-standard.mjs` is its falsifier (a synthetic 3-list toy set runs
|
||||
end-to-end on every gate pass, so the contract cannot rot silently).
|
||||
|
||||
## What a fork supplies — one folder
|
||||
|
||||
```
|
||||
my-set/
|
||||
clusters.json REQUIRED the group-set file (schema below)
|
||||
records.json OPTIONAL your own people rows — with it, no API and no cache are needed
|
||||
selector-evaluator.js REQUIRED resolvable from the folder (re-export ours, or ship your own)
|
||||
normalize.js OPTIONAL absent -> records are used as-is (logged once, never silent)
|
||||
legend-tree.js ONLY for the bucketed legacy shape; a flat set never needs it
|
||||
```
|
||||
|
||||
### clusters.json — the generic (flat) shape
|
||||
|
||||
```json
|
||||
{ "lists": [
|
||||
{ "id": "my-north", "label": "My North", "selector": { "type": "pg_slugs", "values": ["a","b"] } },
|
||||
{ "id": "my-draft", "label": "Draft", "unlisted": true, "selector": { … } }
|
||||
] }
|
||||
```
|
||||
|
||||
- `selector` — any type your evaluator handles (`pg_slugs` needs nothing but slugs).
|
||||
- `unlisted: true` — defined and selectable with `ONLY=<id>`, but not generated by default.
|
||||
This is the reachability idea as data.
|
||||
- The bucketed shape (`lists` + `asia_lists` + `uncovered_lists` + `deaf_lists`) is the UPG-100
|
||||
catalogue's legacy form; its enumeration goes through the legend tree to keep the deaf collapse.
|
||||
New sets use the flat form.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
DATA_DIR=path/to/my-set PACKET_OUT=out/my-packets PACKET_LANG=es node library/scripts/posters/packet.mjs
|
||||
ONLY=my-north … # one list NOPHOTOS=1 … # skip photo fetch
|
||||
```
|
||||
|
||||
- **Locale**: `PACKET_LANG` (never the shell's `LANG`); strings come from `packet-strings.json`
|
||||
per-locale blocks with per-key fallback — a new language is a new block, not a code change.
|
||||
- **Titles**: the per-list `label` + your strings block. Nothing else to configure.
|
||||
- `PACKET_OUT` keeps fork/test output away from the real artifact directory.
|
||||
|
||||
## Pages that depend on optional repo data (they omit cleanly, never fail the run)
|
||||
|
||||
| page | needs | absent → |
|
||||
|---|---|---|
|
||||
| map (page 1) | `out/posters/<id>.html` (`bun run posters`) | cover without the map |
|
||||
| triangle | `data/people-groups/vision-funnel.json` | page omitted |
|
||||
| %-evangelical | `data/people-groups/christian-share.json` | page omitted |
|
||||
| bounded/centered sets | `public/dx-maps/assets/tables/near-neighbors.json` joined by slug | page omitted |
|
||||
|
||||
A fork's first run therefore yields: cover + profiles (+ triangle if the repo file is present) —
|
||||
correct and smaller, per the rule that a page which cannot state true numbers must not appear.
|
||||
|
||||
## Not in scope
|
||||
|
||||
Posters for arbitrary sets (the poster generator needs geo/bbox inputs this contract does not
|
||||
define yet), multi-territory maps, and authoring UI. The in-app catalogue is the plugin's own
|
||||
`data/clusters.json` — a fork edits its copy; the file is the interface.
|
||||
Loading…
Reference in a new issue