@bongos/core 1.19.1056 → 1.19.1057
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +24 -14
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/page-reader.js +674 -0
- package/scripts/gds/ship-check.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/page_reader.mjs +200 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1057",
|
|
6
|
+
"core_contract": "1.19.1057",
|
|
7
|
+
"source_commit": "d501e49e33ddb2b6779e8bd556710c012c3d0cdb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-28T06:35:05.692Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 543,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2513,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3083,
|
|
20
|
+
"tree_sha256": "fea44093e4742e41db41f9cc9c3a345b7f205059ca4cbe3f82b5eb02566df7b7",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2732,7 +2732,7 @@
|
|
|
2732
2732
|
{
|
|
2733
2733
|
"path": "docs/file-map.md",
|
|
2734
2734
|
"mode": "0000644",
|
|
2735
|
-
"sha256": "
|
|
2735
|
+
"sha256": "771b9fdbe001c3e10068a7a3eca13e270e4f017345f851ffac2bc94464bdee22"
|
|
2736
2736
|
},
|
|
2737
2737
|
{
|
|
2738
2738
|
"path": "docs/handoff-template.md",
|
|
@@ -2742,7 +2742,7 @@
|
|
|
2742
2742
|
{
|
|
2743
2743
|
"path": "docs/module-api-changelog.md",
|
|
2744
2744
|
"mode": "0000644",
|
|
2745
|
-
"sha256": "
|
|
2745
|
+
"sha256": "f2b7d0470a3301710a06171c98e6fe5e512f234e566317116039b0d684d6dc92"
|
|
2746
2746
|
},
|
|
2747
2747
|
{
|
|
2748
2748
|
"path": "docs/modules-contract.md",
|
|
@@ -8467,12 +8467,12 @@
|
|
|
8467
8467
|
{
|
|
8468
8468
|
"path": "package-lock.json",
|
|
8469
8469
|
"mode": "0000644",
|
|
8470
|
-
"sha256": "
|
|
8470
|
+
"sha256": "9df4cf4a5ad66ced9b1f6868540c91d6fa25a89405926352c63d6469398b2ec9"
|
|
8471
8471
|
},
|
|
8472
8472
|
{
|
|
8473
8473
|
"path": "package.json",
|
|
8474
8474
|
"mode": "0000644",
|
|
8475
|
-
"sha256": "
|
|
8475
|
+
"sha256": "3fca2502e59fa31b4575a0bfa09712cceea4114940af579d38f2852feb1aaaf4"
|
|
8476
8476
|
},
|
|
8477
8477
|
{
|
|
8478
8478
|
"path": "public-docs/index.html",
|
|
@@ -8492,7 +8492,7 @@
|
|
|
8492
8492
|
{
|
|
8493
8493
|
"path": "release-notes.json",
|
|
8494
8494
|
"mode": "0000644",
|
|
8495
|
-
"sha256": "
|
|
8495
|
+
"sha256": "7929f537dc60f10bad4ca1a0929bdec87bb6f5508997f82fa25c9b788777b3fb"
|
|
8496
8496
|
},
|
|
8497
8497
|
{
|
|
8498
8498
|
"path": "scripts/bongos-mcp.js",
|
|
@@ -9394,6 +9394,11 @@
|
|
|
9394
9394
|
"mode": "0000644",
|
|
9395
9395
|
"sha256": "f4b676c3949015733bc3f1bcaf369c2ab1225583b0200f862c25a6f0f18d3873"
|
|
9396
9396
|
},
|
|
9397
|
+
{
|
|
9398
|
+
"path": "scripts/gds/page-reader.js",
|
|
9399
|
+
"mode": "0000644",
|
|
9400
|
+
"sha256": "aae55494f204111e5511729bf10bab6fade4ba2b9727c4155ab76b2b7b80054b"
|
|
9401
|
+
},
|
|
9397
9402
|
{
|
|
9398
9403
|
"path": "scripts/gds/paste-token.js",
|
|
9399
9404
|
"mode": "0000644",
|
|
@@ -9852,7 +9857,7 @@
|
|
|
9852
9857
|
{
|
|
9853
9858
|
"path": "scripts/gds/ship-check.js",
|
|
9854
9859
|
"mode": "0000644",
|
|
9855
|
-
"sha256": "
|
|
9860
|
+
"sha256": "7a51c161729e549e42f3258695e91c6500c147fd56b4b571f7599bcd4f83e97b"
|
|
9856
9861
|
},
|
|
9857
9862
|
{
|
|
9858
9863
|
"path": "scripts/gds/ship-deploy-target.js",
|
|
@@ -10572,7 +10577,7 @@
|
|
|
10572
10577
|
{
|
|
10573
10578
|
"path": "src/module-api.js",
|
|
10574
10579
|
"mode": "0000644",
|
|
10575
|
-
"sha256": "
|
|
10580
|
+
"sha256": "359179dcb9b13435adf5c0f8e55fe24d1f1edea46756f13cfd1d02c8d343abaf"
|
|
10576
10581
|
},
|
|
10577
10582
|
{
|
|
10578
10583
|
"path": "src/module-loader/catalog.js",
|
|
@@ -13644,6 +13649,11 @@
|
|
|
13644
13649
|
"mode": "0000644",
|
|
13645
13650
|
"sha256": "363855afde70c909be7b6392d36663848c823afed0131a8785286b7b93ca321b"
|
|
13646
13651
|
},
|
|
13652
|
+
{
|
|
13653
|
+
"path": "tests/page_reader.mjs",
|
|
13654
|
+
"mode": "0000644",
|
|
13655
|
+
"sha256": "2d86f24827327f8015feec80de6fb1089d7f5d5589847a1c0cf4fad59412d307"
|
|
13656
|
+
},
|
|
13647
13657
|
{
|
|
13648
13658
|
"path": "tests/parallel-load.mjs",
|
|
13649
13659
|
"mode": "0000644",
|
package/docs/file-map.md
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
│ ├── copy-inventory.md ← GENERATED twin of copy-registry.json: the human-readable copy report, including the "what this cannot see" section (server-side messages, skill prose, Discord copy, runtime-composed strings, and the src/ui-vs-reality surface mismatch)
|
|
51
51
|
│ ├── page-inventory.json ← GENERATED (scripts/gds/page-inventory.js, BV2.TW02 task 1004314): every page of every enabled web surface (builders hall, landing, status) with its stable id <surface>:<page>, title, portable url, the files that render it and its .states.json states. Tweak Mode keys page status, claims and pay on these ids; regenerate + commit, never hand-edit
|
|
52
52
|
│ ├── page-inventory.md ← GENERATED twin of page-inventory.json: the id rule, then one table per surface
|
|
53
|
+
│ ├── page-readings.json ← GENERATED (scripts/gds/page-reader.js, BV2.TW04 task 1004315, ADR 0341 D3): every inventory page rendered in each state, its visible strings in page order by section, each placed (registry row), shared (shell text) or unplaced; reading_hash + files_hash per page. Re-read with page-reader.js --stale, never hand-edit
|
|
53
54
|
│ ├── handoff-template.md ← session-end template + Definition of Done
|
|
54
55
|
│ ├── module-api-changelog.md ← the `CORE_VERSION` release ledger for the published doorway (ADR 0203, task 1003335). MOVED here out of src/module-api.js: the ADR 0161 CI carrier appends an entry on every release and pushes straight to main, which grew that .js file past the 1500-line oversized-file ratchet and turned `unit` red for main and every open PR. Markdown is exempt from the .js line budget. Written by scripts/gds/bump-version.js (entries oldest-first, each inserted above the closing divider); the CORE_VERSION literal itself stays in src/module-api.js
|
|
55
56
|
│ ├── google-chat-broadcast.md ← team-chat integration plan
|
|
@@ -202,6 +203,7 @@
|
|
|
202
203
|
│ ├── doc-comment-coverage.js ← ADR 0095 / task 1001654: the ADVISORY doc-comment nudge (fitness Check 28) — reports source files under src/·scripts/·modules/ that open with no module header. Never hard-fails: a doc-comment gate that blocks a ship buys filler prose, and making it blocking needs owner sign-off + its own ADR. CRLF-safe (unlike docs-entropy.js); an empty enumeration throws as a scan defect
|
|
203
204
|
│ ├── copy-inventory.js ← R01 of goal 1000074 (the artist's loop): the COPY REGISTRY — every user-facing string across the hall / status / landing surfaces, each row naming the render path that shows it (html-text · html-attr · js-markup · js-notice · js-text-assign) + a stable id that survives a move. Writes docs/copy-registry.json + docs/copy-inventory.md; `--check` fails on drift. Precision over recall: a JS literal with no render path is not listed
|
|
204
205
|
│ ├── page-inventory.js ← BV2.TW02 (task 1004314): the PAGE INVENTORY — surfaces from the loader's moduleWebSurfaces() over the ENABLED modules (no hardcoded list), pages from each surface's *.html, ids <surface>:<page> (page = body[data-page] else the file name). Writes docs/page-inventory.json + .md; `--check` (run by fitness.js + ship-check.js) fails on drift, a duplicate id or an empty surface
|
|
206
|
+
│ ├── page-reader.js ← BV2.TW04 (task 1004315, ADR 0341 D3): the PAGE READER — renders every page of docs/page-inventory.json in each state (hall-preview harness + ui-design kit stub, one browser, about two minutes for all 37) and writes docs/page-readings.json; `--check` (ship-check only, no browser) compares files_hash; `--stale` re-reads the stale pages
|
|
205
207
|
│ ├── copy-apply.js ← R03 of goal 1000074 (ADR 0233): lands a copy proposal as a REAL DIFF under a claim — the design half’s adapter.export() applied to text. Reads the fenced copy-proposal patch off a task (--task N; --dry-run, --patch-file), RE-DERIVES the string’s position by re-running copy-inventory’s own extractor over the working tree (the registry commits no offsets — they go stale, the id does not), re-injects the source’s own ${expr} into the proposal’s {…} holes, writes, regenerates the registry and verifies its own edit. Refuses BY NAME on target_gone / ambiguous_target / no_exact_span / placeholder_mismatch / path_outside_surface. The ONLY writer in the copy feature, and deliberately a command rather than a route
|
|
206
208
|
│ ├── gen-role-responsibilities.js ← writes each role pack's written-responsibility block from src/role-responsibilities.js (task 1003732, ADR 0275). Targets come from the role registry, not a hardcoded list, so a renamed pack or a fourth craft needs no edit here; the block takes the pack's own line ending (the packs are CRLF on Windows, LF in CI). `--check` is the CI gate, run by role-pack-guard.js — never hand-edit a block
|
|
207
209
|
│ ├── role-pack-guard.js ← the fitness check over the role packs (task 1002990, ADR 0274): every registry entry resolves to a real pack, every pack fits its char budget, three crafts carry one, and each pack's responsibility block is current. HARD-fails because claim.js reads the registry fail-open, so a broken pack path is silent at runtime
|
|
@@ -2599,5 +2599,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2599
2599
|
landed since 1.19.1054 with no explicit bump. run 36380323033. (task 1002620)
|
|
2600
2600
|
1.19.1056 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2601
2601
|
landed since 1.19.1055 with no explicit bump. run 36382270561. (task 1002620)
|
|
2602
|
+
1.19.1057 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2603
|
+
landed since 1.19.1056 with no explicit bump. run 36387147130. (task 1002620)
|
|
2602
2604
|
---------------------------------------------------------------------------
|
|
2603
2605
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1057",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1057",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1057",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7637,5 +7637,11 @@
|
|
|
7637
7637
|
"id": "1004332",
|
|
7638
7638
|
"text": "Tweak Mode now has one written plan that every later step builds on. It says how a page is taken, where the artist's words rest while they write, how a page shows as tweaked or changed since, how the Word download and upload w"
|
|
7639
7639
|
}
|
|
7640
|
+
],
|
|
7641
|
+
"1.19.1057": [
|
|
7642
|
+
{
|
|
7643
|
+
"id": "1004315",
|
|
7644
|
+
"text": "Every page of the project now has a written reading: all the words it actually shows, in order, grouped under its headings. Each line says where the words live. Some come from a known place in the code, some are shared by ever"
|
|
7645
|
+
}
|
|
7640
7646
|
]
|
|
7641
7647
|
}
|
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// scripts/gds/page-reader.js — the PAGE READER: every string a rendered page
|
|
5
|
+
// shows, in page order, grouped by section, each placed against the copy
|
|
6
|
+
// registry (BV2.TW04, task 1004315; ADR 0341 D3; goal 1000095, the Tweak Mode
|
|
7
|
+
// chain — docs/specs/bongos-v2-tweak-mode.md).
|
|
8
|
+
//
|
|
9
|
+
// WHY. The copy registry (docs/copy-registry.json) is keyed by FILE: it lists
|
|
10
|
+
// comment text nobody sees, and misses anything a script builds at run time or a
|
|
11
|
+
// server sends. Tweak Mode's unit is the PAGE (ADR 0341 D1), so an artist needs
|
|
12
|
+
// the other view — "what does this page actually show". This reader renders each
|
|
13
|
+
// page of docs/page-inventory.json in each of its states, lists every visible
|
|
14
|
+
// string, and says for each line where it lives: PLACED (a registry row: file,
|
|
15
|
+
// line, string_id), SHARED (shell text — the top bar, sidebar and footer every
|
|
16
|
+
// page of a surface carries, the planning session's "same UI element") or
|
|
17
|
+
// UNPLACED (a string the registry cannot place; ADR 0341 D4 turns a rewrite of
|
|
18
|
+
// one into its own engineer task). An unplaced string is LISTED, never dropped.
|
|
19
|
+
//
|
|
20
|
+
// THE OUTPUT CONTRACT (ADR 0341 D3). docs/page-readings.json, generated and
|
|
21
|
+
// committed: one entry per inventory page, its lines in page order. Each line has
|
|
22
|
+
// key L0001, L0002, … in page order — stable only within one reading;
|
|
23
|
+
// section the heading the line sits under (or the shell region for shared);
|
|
24
|
+
// text the string, with {…} holes where the registry row has a hole or a
|
|
25
|
+
// run-time value (data from the page's own reads) was filled in;
|
|
26
|
+
// placement placed | shared | unplaced, with file, line and string_id
|
|
27
|
+
// whenever the registry places the string (shared lines too, so a
|
|
28
|
+
// shared edit can travel in a batch like a placed one, D4).
|
|
29
|
+
// Each page carries reading_hash (over its NON-shared lines' key, section and
|
|
30
|
+
// text — so a shell change does not move every page's hash) and files_hash (over
|
|
31
|
+
// the contents of its inventory `files`, line endings normalised).
|
|
32
|
+
//
|
|
33
|
+
// FRESHNESS WITHOUT A BROWSER. --check compares files_hash only — pure fs, no
|
|
34
|
+
// browser, no network — and names the command that re-reads the stale pages.
|
|
35
|
+
// ship-check runs it; nothing inside ship-check or fitness launches a browser
|
|
36
|
+
// (D3). The browser runs only when a person or session runs this reader.
|
|
37
|
+
//
|
|
38
|
+
// HOW IT RENDERS. The hall through the hall-preview harness
|
|
39
|
+
// (scripts/hall-preview/server.js --fixture-me: the real pages, the checked-in
|
|
40
|
+
// fixtures, one pinned account), landing and status through the ui-design kit's
|
|
41
|
+
// stub (modules/ui-design/kit/serve.js: the real serving transforms, the kit's
|
|
42
|
+
// public feeds), the browser through the kit's launchBrowser (system Edge or
|
|
43
|
+
// Chrome via playwright-core; nothing is downloaded). Each state is rendered at
|
|
44
|
+
// 1280x800, light (or the page's one pinned mode), with the clock pinned so a
|
|
45
|
+
// relative date reads the same on every run, then its states-file actions run.
|
|
46
|
+
// Four renders run at once. Measured on the owner's Windows box (2026-09-27): the
|
|
47
|
+
// whole inventory, 37 pages and 156 renders, in 128 seconds; one page in 5-40
|
|
48
|
+
// seconds (--page). Two back-to-back full runs wrote byte-identical files.
|
|
49
|
+
//
|
|
50
|
+
// Run: node scripts/gds/page-reader.js (re-read every page, write the readings)
|
|
51
|
+
// node scripts/gds/page-reader.js --stale (re-read only the pages --check calls stale)
|
|
52
|
+
// node scripts/gds/page-reader.js --page builders:studio,landing:index
|
|
53
|
+
// node scripts/gds/page-reader.js --check (no browser: exit 1 naming each stale page)
|
|
54
|
+
// node scripts/gds/page-reader.js --page <id> --print (read one page, print it, write nothing)
|
|
55
|
+
|
|
56
|
+
const fs = require('node:fs');
|
|
57
|
+
const path = require('node:path');
|
|
58
|
+
const crypto = require('node:crypto');
|
|
59
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
60
|
+
|
|
61
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
62
|
+
const INVENTORY_REL = 'docs/page-inventory.json';
|
|
63
|
+
const REGISTRY_REL = 'docs/copy-registry.json';
|
|
64
|
+
const READINGS_REL = 'docs/page-readings.json';
|
|
65
|
+
const REREAD_COMMAND = 'node scripts/gds/page-reader.js --stale';
|
|
66
|
+
const PLACEMENTS = Object.freeze(['placed', 'shared', 'unplaced']);
|
|
67
|
+
const HOLE = '{…}';
|
|
68
|
+
const VIEWPORT = Object.freeze({ width: 1280, height: 800 });
|
|
69
|
+
// A fixed instant for every render, so "3 days ago" is the same on every run. Only
|
|
70
|
+
// Date is pinned; timers keep running, so the pages still boot.
|
|
71
|
+
const PINNED_CLOCK = '2026-09-27T12:00:00Z';
|
|
72
|
+
const CONCURRENCY = 4;
|
|
73
|
+
const EXTRA_SETTLE_MS = 600; // on top of the kit's SETTLE_MS: late fetches, the harness's SSE nudge
|
|
74
|
+
const ACTION_TIMEOUT_MS = 6000;
|
|
75
|
+
const RENDER_CEILING_MS = 30000;
|
|
76
|
+
|
|
77
|
+
// Locale-independent order (see copy-inventory.js: localeCompare depends on ICU).
|
|
78
|
+
function cmp(a, b) {
|
|
79
|
+
if (a === b) return 0;
|
|
80
|
+
return a < b ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sha(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
|
|
84
|
+
|
|
85
|
+
// ── the hashes ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
// files_hash — over the contents of a page's inventory files, CRLF folded to LF
|
|
88
|
+
// (a Windows checkout and CI must agree). A missing file hashes as absent, so
|
|
89
|
+
// deleting one moves the hash too.
|
|
90
|
+
function filesHash(files, { root = REPO_ROOT, fsImpl = fs } = {}) {
|
|
91
|
+
const h = crypto.createHash('sha256');
|
|
92
|
+
for (const f of files) {
|
|
93
|
+
let body;
|
|
94
|
+
try { body = fsImpl.readFileSync(path.join(root, f), 'utf8').replace(/\r\n/g, '\n'); } catch { body = '\u0000absent'; }
|
|
95
|
+
h.update(`${f}\n${body.length}\n${body}\n`);
|
|
96
|
+
}
|
|
97
|
+
return h.digest('hex').slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// reading_hash — over the non-shared lines only (ADR 0341 D3). The keys are in it,
|
|
101
|
+
// so a shell line added above the page's own text (which renumbers them) still
|
|
102
|
+
// moves the hash, and a draft keyed on the old numbering is refused, not misread.
|
|
103
|
+
function readingHash(lines) {
|
|
104
|
+
const own = lines.filter((l) => l.placement !== 'shared').map((l) => [l.key, l.section, l.text]);
|
|
105
|
+
return sha(JSON.stringify(own)).slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── the registry matcher (pure) ──────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function decodeEntities(s) {
|
|
111
|
+
return String(s)
|
|
112
|
+
.replace(/ /g, ' ').replace(/—/g, '—').replace(/–/g, '–').replace(/…/g, '…')
|
|
113
|
+
.replace(/’/g, '’').replace(/‘/g, '‘').replace(/”/g, '”').replace(/“/g, '“')
|
|
114
|
+
.replace(/·/g, '·').replace(/×/g, '×').replace(/→/g, '→').replace(/←/g, '←')
|
|
115
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>')
|
|
116
|
+
.replace(/&#(\d+);/g, (m, n) => String.fromCodePoint(Number(n)))
|
|
117
|
+
.replace(/&/g, '&');
|
|
118
|
+
}
|
|
119
|
+
function norm(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); }
|
|
120
|
+
const HAS_LETTER = /\p{L}/u;
|
|
121
|
+
// The registry's holes: ${…} (a template expression), {…} (the same, written
|
|
122
|
+
// plain) and {{token}} (a branding placeholder the server fills).
|
|
123
|
+
const REGISTRY_HOLE = /\$\{…\}|\{\{[A-Za-z0-9_.]+\}\}|\{…\}/g;
|
|
124
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
125
|
+
// The reading's own text for a registry row: ${…} written as the registry's plain
|
|
126
|
+
// {…}; a {{token}} is kept by name (it is a brand value, and says which).
|
|
127
|
+
function registryText(text) { return norm(decodeEntities(text)).replace(/\$\{…\}/g, HOLE); }
|
|
128
|
+
|
|
129
|
+
// buildMatcher — index the registry once: exact texts, holed patterns, and the
|
|
130
|
+
// literal segments between holes (a hole filled with markup splits one row into
|
|
131
|
+
// several text nodes, each a segment of it).
|
|
132
|
+
function buildMatcher(registry) {
|
|
133
|
+
const entries = (registry && registry.entries) || [];
|
|
134
|
+
const exact = new Map();
|
|
135
|
+
const segments = new Map();
|
|
136
|
+
const patterns = [];
|
|
137
|
+
const push = (map, k, v) => { if (!map.has(k)) map.set(k, []); map.get(k).push(v); };
|
|
138
|
+
entries.forEach((e, order) => {
|
|
139
|
+
const text = norm(decodeEntities(e.text));
|
|
140
|
+
if (!text || !HAS_LETTER.test(text)) return;
|
|
141
|
+
const row = { id: e.id, file: e.file, line: e.line, text: registryText(e.text), order };
|
|
142
|
+
const parts = text.split(REGISTRY_HOLE);
|
|
143
|
+
if (parts.length === 1) { push(exact, text, row); return; }
|
|
144
|
+
const literal = parts.join('');
|
|
145
|
+
if ((literal.match(/\p{L}/gu) || []).length < 2) return; // a row that is all holes places nothing
|
|
146
|
+
patterns.push({ row, re: new RegExp(`^${parts.map((p) => escapeRe(p)).join('(.*?)')}$`, 'su') });
|
|
147
|
+
for (const p of parts) { const seg = norm(p); if (seg.length >= 2 && HAS_LETTER.test(seg)) push(segments, seg, row); }
|
|
148
|
+
});
|
|
149
|
+
return { exact, segments, patterns, size: entries.length };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// placeText — the registry row that renders this string on this page, or null.
|
|
153
|
+
// Tiers in order: the exact text, a holed row whose pattern matches it whole, a
|
|
154
|
+
// literal segment of a holed row. Within a tier, a row in one of the page's own
|
|
155
|
+
// files wins (in the page's file order: its html first), then the lowest line; a
|
|
156
|
+
// row in a script or sheet elsewhere on the same surface is the fallback (one the
|
|
157
|
+
// page loads late, which the inventory's script tags cannot see). Anything else is
|
|
158
|
+
// unplaced — the caller lists it, it is never dropped.
|
|
159
|
+
function placeText(text, matcher, { files = [], surfaceDir = null } = {}) {
|
|
160
|
+
const t = norm(text);
|
|
161
|
+
if (!t) return null;
|
|
162
|
+
const rank = new Map(files.map((f, i) => [f, i]));
|
|
163
|
+
const pick = (rows) => {
|
|
164
|
+
if (!rows || !rows.length) return null;
|
|
165
|
+
const own = rows.filter((r) => rank.has(r.file)).sort((a, b) => rank.get(a.file) - rank.get(b.file) || a.line - b.line || a.order - b.order);
|
|
166
|
+
if (own.length) return own[0];
|
|
167
|
+
// Never another page's html: a page file renders only itself, so a row there
|
|
168
|
+
// is the same words on a different page, and an applier would edit the wrong one.
|
|
169
|
+
const near = surfaceDir ? rows.filter((r) => r.file.startsWith(`${surfaceDir}/`) && !/\.html?$/i.test(r.file)).sort((a, b) => cmp(a.file, b.file) || a.line - b.line) : [];
|
|
170
|
+
return near[0] || null;
|
|
171
|
+
};
|
|
172
|
+
const tiers = [
|
|
173
|
+
() => ({ row: pick(matcher.exact.get(t)), how: 'exact' }),
|
|
174
|
+
() => ({ row: pick(matcher.patterns.filter((p) => p.re.test(t)).map((p) => p.row)), how: 'pattern' }),
|
|
175
|
+
() => ({ row: pick(matcher.segments.get(t)), how: 'segment' }),
|
|
176
|
+
];
|
|
177
|
+
for (const tier of tiers) {
|
|
178
|
+
const { row, how } = tier();
|
|
179
|
+
if (row) return { file: row.file, line: row.line, string_id: row.id, text: how === 'pattern' ? row.text : t, how };
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── data values: what the page READ, not what it SAYS ─────────────────────────
|
|
185
|
+
|
|
186
|
+
// A page shows its data too — task titles, names, counts from its own API reads.
|
|
187
|
+
// Those are not copy: an artist cannot rewrite a fixture's task title. A string
|
|
188
|
+
// the registry cannot place is compared against the values the page actually
|
|
189
|
+
// received: every such value in it becomes a {…} hole, and a string that is
|
|
190
|
+
// nothing BUT data once holed (no letter left) is data, counted and not listed.
|
|
191
|
+
// The registry is always asked first, so a placed string is never taken for data.
|
|
192
|
+
function collectStrings(value, out = new Set()) {
|
|
193
|
+
if (typeof value === 'string') { const s = norm(value); if (s.length >= 2 && s.length <= 400 && HAS_LETTER.test(s)) out.add(s); }
|
|
194
|
+
else if (Array.isArray(value)) for (const v of value) collectStrings(v, out);
|
|
195
|
+
else if (value && typeof value === 'object') for (const v of Object.values(value)) collectStrings(v, out);
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
const TIME_RE = /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?(?![\p{L}\p{N}])/gu;
|
|
199
|
+
const DATE_RE = /\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.? \d{1,2}(?:, \d{4})?\b|\b\d{4}-\d{2}-\d{2}(?:[T ][\d:.]+Z?)?\b/g;
|
|
200
|
+
function holeData(text, dataValues) {
|
|
201
|
+
const t = norm(text);
|
|
202
|
+
if (dataValues.has(t)) return { text: HOLE, data: true };
|
|
203
|
+
let out = t;
|
|
204
|
+
const vals = [...dataValues].filter((v) => v.length >= 4 && out.includes(v)).sort((a, b) => b.length - a.length || cmp(a, b));
|
|
205
|
+
for (const v of vals) {
|
|
206
|
+
const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRe(v)}(?=$|[^\\p{L}\\p{N}])`, 'gu');
|
|
207
|
+
out = out.replace(re, (m, pre) => `${pre}${HOLE}`);
|
|
208
|
+
}
|
|
209
|
+
// Clock times and calendar dates are run-time values too, and a stub may stamp
|
|
210
|
+
// its fixtures relative to its own clock, which the page's pinned Date cannot
|
|
211
|
+
// reach — holed, so a re-read of an unchanged page reads the same.
|
|
212
|
+
out = out.replace(TIME_RE, HOLE).replace(DATE_RE, HOLE);
|
|
213
|
+
const residue = out.split(HOLE).join('');
|
|
214
|
+
return { text: out, data: !HAS_LETTER.test(residue) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── assembling one page's reading (pure) ─────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
// mergeStates — the default state's strings in page order, then each other
|
|
220
|
+
// state's strings not already there, inserted after the last string the two
|
|
221
|
+
// share, so a panel a state opens reads where it sits on the page.
|
|
222
|
+
function mergeStates(captures) {
|
|
223
|
+
const merged = [];
|
|
224
|
+
const id = (s) => `${s.shared || ''}\u0000${s.section}\u0000${s.text}`;
|
|
225
|
+
captures.forEach(({ state, strings }, si) => {
|
|
226
|
+
let at = 0;
|
|
227
|
+
const seen = new Map();
|
|
228
|
+
for (const s of strings) {
|
|
229
|
+
const k = id(s);
|
|
230
|
+
const n = (seen.get(k) || 0) + 1;
|
|
231
|
+
seen.set(k, n);
|
|
232
|
+
// the nth occurrence of this identity in the merged list, at or after `at`
|
|
233
|
+
let hit = -1, count = 0;
|
|
234
|
+
for (let i = 0; i < merged.length; i += 1) {
|
|
235
|
+
if (id(merged[i]) !== k) continue;
|
|
236
|
+
count += 1;
|
|
237
|
+
if (count === n) { hit = i; break; }
|
|
238
|
+
}
|
|
239
|
+
if (hit >= 0) { at = Math.max(at, hit + 1); continue; }
|
|
240
|
+
merged.splice(at, 0, { ...s, state: si === 0 ? null : state });
|
|
241
|
+
at += 1;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
return merged;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// assembleReading — one page's strings (per state, as captured) into its reading.
|
|
248
|
+
function assembleReading(page, captures, matcher, { dataValues = new Set(), surfaceDir = null, root = REPO_ROOT, fsImpl = fs } = {}) {
|
|
249
|
+
const files = page.files || [];
|
|
250
|
+
const placeCache = new Map();
|
|
251
|
+
const place = (t) => {
|
|
252
|
+
if (!placeCache.has(t)) placeCache.set(t, placeText(t, matcher, { files, surfaceDir }));
|
|
253
|
+
return placeCache.get(t);
|
|
254
|
+
};
|
|
255
|
+
const sectionName = (raw, shared) => {
|
|
256
|
+
if (shared) return shared;
|
|
257
|
+
const t = norm(raw);
|
|
258
|
+
if (!t) return 'page top';
|
|
259
|
+
const hit = place(t);
|
|
260
|
+
const text = hit ? hit.text : holeData(t, dataValues).text;
|
|
261
|
+
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
|
|
262
|
+
};
|
|
263
|
+
const merged = mergeStates(captures.map((c) => ({ state: c.state, strings: c.strings.map((s) => ({ ...s, text: norm(s.text), section: norm(s.section) })) })));
|
|
264
|
+
const lines = [];
|
|
265
|
+
const index = new Map();
|
|
266
|
+
let omittedData = 0;
|
|
267
|
+
for (const s of merged) {
|
|
268
|
+
if (!s.text || !HAS_LETTER.test(s.text)) continue; // a number, a glyph, punctuation: nothing to reword
|
|
269
|
+
const section = sectionName(s.section, s.shared);
|
|
270
|
+
const hit = place(s.text);
|
|
271
|
+
let line;
|
|
272
|
+
if (hit) {
|
|
273
|
+
line = { section, text: hit.text, placement: s.shared ? 'shared' : 'placed', file: hit.file, line: hit.line, string_id: hit.string_id };
|
|
274
|
+
} else {
|
|
275
|
+
const held = holeData(s.text, dataValues);
|
|
276
|
+
if (held.data) { omittedData += 1; continue; }
|
|
277
|
+
line = { section, text: held.text, placement: s.shared ? 'shared' : 'unplaced' };
|
|
278
|
+
}
|
|
279
|
+
if (s.attr) line.attr = s.attr;
|
|
280
|
+
if (s.state) line.state = s.state;
|
|
281
|
+
const k = `${line.section}\u0000${line.text}\u0000${line.placement}\u0000${line.string_id || ''}\u0000${line.attr || ''}`;
|
|
282
|
+
if (index.has(k)) { const first = index.get(k); first.occurrences = (first.occurrences || 1) + 1; continue; }
|
|
283
|
+
index.set(k, line);
|
|
284
|
+
lines.push(line);
|
|
285
|
+
}
|
|
286
|
+
const keyed = lines.map((l, i) => ({ key: `L${String(i + 1).padStart(4, '0')}`, ...l }));
|
|
287
|
+
const counts = { lines: keyed.length };
|
|
288
|
+
for (const p of PLACEMENTS) counts[p] = keyed.filter((l) => l.placement === p).length;
|
|
289
|
+
return {
|
|
290
|
+
id: page.id,
|
|
291
|
+
surface: page.surface,
|
|
292
|
+
title: page.title,
|
|
293
|
+
states_read: captures.map((c) => c.state),
|
|
294
|
+
files_hash: filesHash(files, { root, fsImpl }),
|
|
295
|
+
reading_hash: readingHash(keyed),
|
|
296
|
+
counts,
|
|
297
|
+
omitted: { data: omittedData },
|
|
298
|
+
notes: [...new Set(captures.flatMap((c) => c.notes || []))],
|
|
299
|
+
lines: keyed,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── the artifact ─────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
const HOW = [
|
|
306
|
+
'Generated by node scripts/gds/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.',
|
|
307
|
+
'One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.',
|
|
308
|
+
'placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).',
|
|
309
|
+
'text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.',
|
|
310
|
+
'reading_hash is over the non-shared lines (key, section, text); files_hash is over the page\'s inventory files. --check compares files_hash only and never opens a browser.',
|
|
311
|
+
'Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world\'s page.',
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
function summaryCounts(pages) {
|
|
315
|
+
const c = { pages: pages.length, lines: 0, placed: 0, shared: 0, unplaced: 0, omitted_data: 0 };
|
|
316
|
+
for (const p of pages) {
|
|
317
|
+
c.lines += p.counts.lines;
|
|
318
|
+
for (const k of PLACEMENTS) c[k] += p.counts[k];
|
|
319
|
+
c.omitted_data += p.omitted.data;
|
|
320
|
+
}
|
|
321
|
+
return c;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// One line per reading line, so a re-read of one page is a diff of that page's
|
|
325
|
+
// block, and two branches re-reading different pages merge cleanly.
|
|
326
|
+
function serialize(doc) {
|
|
327
|
+
const out = [];
|
|
328
|
+
out.push('{');
|
|
329
|
+
for (const k of ['schema', 'generator', 'task', 'adr', 'reread', 'how', 'counts']) out.push(` ${JSON.stringify(k)}: ${JSON.stringify(doc[k])},`);
|
|
330
|
+
out.push(' "pages": [');
|
|
331
|
+
doc.pages.forEach((p, pi) => {
|
|
332
|
+
out.push(' {');
|
|
333
|
+
for (const k of ['id', 'surface', 'title', 'states_read', 'files_hash', 'reading_hash', 'counts', 'omitted', 'notes']) out.push(` ${JSON.stringify(k)}: ${JSON.stringify(p[k])},`);
|
|
334
|
+
out.push(' "lines": [');
|
|
335
|
+
p.lines.forEach((l, li) => out.push(` ${JSON.stringify(l)}${li < p.lines.length - 1 ? ',' : ''}`));
|
|
336
|
+
out.push(' ]');
|
|
337
|
+
out.push(` }${pi < doc.pages.length - 1 ? ',' : ''}`);
|
|
338
|
+
});
|
|
339
|
+
out.push(' ]');
|
|
340
|
+
out.push('}');
|
|
341
|
+
return `${out.join('\n')}\n`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildDoc(pages) {
|
|
345
|
+
return {
|
|
346
|
+
schema: 1,
|
|
347
|
+
generator: 'scripts/gds/page-reader.js',
|
|
348
|
+
task: 1004315,
|
|
349
|
+
adr: '0341 D3',
|
|
350
|
+
reread: REREAD_COMMAND,
|
|
351
|
+
how: HOW,
|
|
352
|
+
counts: summaryCounts(pages),
|
|
353
|
+
pages,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readJsonFile(rel, root = REPO_ROOT) {
|
|
358
|
+
return JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// checkReadings — the browser-free freshness check (ADR 0341 D3): a page is stale
|
|
362
|
+
// when it has no reading, or its files_hash no longer matches its files. A reading
|
|
363
|
+
// for a page the inventory no longer lists is stale too (an extra).
|
|
364
|
+
function checkReadings(inventory, readings, { root = REPO_ROOT, fsImpl = fs } = {}) {
|
|
365
|
+
const byId = new Map(((readings && readings.pages) || []).map((p) => [p.id, p]));
|
|
366
|
+
const stale = [];
|
|
367
|
+
for (const page of inventory.pages) {
|
|
368
|
+
const r = byId.get(page.id);
|
|
369
|
+
if (!r) { stale.push({ id: page.id, why: 'no reading' }); continue; }
|
|
370
|
+
if (r.files_hash !== filesHash(page.files, { root, fsImpl })) stale.push({ id: page.id, why: 'its files changed' });
|
|
371
|
+
}
|
|
372
|
+
const listed = new Set(inventory.pages.map((p) => p.id));
|
|
373
|
+
for (const id of byId.keys()) if (!listed.has(id)) stale.push({ id, why: 'not in the inventory' });
|
|
374
|
+
return stale;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── rendering (the browser half; never reached by --check) ───────────────────
|
|
378
|
+
|
|
379
|
+
// Runs IN THE PAGE: every visible string in document order, each with the heading
|
|
380
|
+
// it sits under and, when it is shell text, the shell region. Self-contained (it is
|
|
381
|
+
// serialised into the page).
|
|
382
|
+
function captureInPage() {
|
|
383
|
+
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
384
|
+
const main = document.querySelector('main');
|
|
385
|
+
const SHELL = [['#app-topbar', 'top bar'], ['#app-sidebar', 'sidebar'], ['#app-foot', 'footer'], ['a.skip', 'skip link']];
|
|
386
|
+
const sharedOf = (el) => {
|
|
387
|
+
for (const [sel, name] of SHELL) if (el.closest(sel)) return name;
|
|
388
|
+
if (main && main.contains(el)) return null;
|
|
389
|
+
const lm = el.closest('header, footer, nav, [role="banner"], [role="contentinfo"], [role="navigation"]');
|
|
390
|
+
if (!lm || (main && lm.contains(main))) return null;
|
|
391
|
+
const tag = lm.tagName.toLowerCase(), role = lm.getAttribute('role');
|
|
392
|
+
if (tag === 'footer' || role === 'contentinfo') return 'footer';
|
|
393
|
+
if (tag === 'nav' || role === 'navigation') return 'navigation';
|
|
394
|
+
return 'top bar';
|
|
395
|
+
};
|
|
396
|
+
const shown = (el) => {
|
|
397
|
+
if (!el || !el.isConnected) return false;
|
|
398
|
+
if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, opacityProperty: true, visibilityProperty: true })) return false;
|
|
399
|
+
const r = el.getBoundingClientRect();
|
|
400
|
+
return r.width > 1 && r.height > 1; // a 1px box is screen-reader-only text
|
|
401
|
+
};
|
|
402
|
+
const textShown = (node) => {
|
|
403
|
+
const el = node.parentElement;
|
|
404
|
+
const select = el && el.closest('select');
|
|
405
|
+
if (select) return shown(select);
|
|
406
|
+
if (!shown(el)) return false;
|
|
407
|
+
const range = document.createRange();
|
|
408
|
+
range.selectNodeContents(node);
|
|
409
|
+
return [...range.getClientRects()].some((r) => r.width > 1 && r.height > 1);
|
|
410
|
+
};
|
|
411
|
+
const out = [];
|
|
412
|
+
let section = '';
|
|
413
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
|
414
|
+
acceptNode(n) {
|
|
415
|
+
if (n.nodeType === 1 && /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(n.tagName)) return NodeFilter.FILTER_REJECT;
|
|
416
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
420
|
+
if (n.nodeType === 1) {
|
|
421
|
+
if (/^H[1-3]$/.test(n.tagName) && shown(n) && !sharedOf(n)) section = clean(n.textContent);
|
|
422
|
+
if ((n.tagName === 'INPUT' || n.tagName === 'TEXTAREA') && shown(n)) {
|
|
423
|
+
const ph = clean(n.getAttribute('placeholder'));
|
|
424
|
+
if (ph) out.push({ text: ph, section, shared: sharedOf(n), attr: 'placeholder' });
|
|
425
|
+
if (n.tagName === 'INPUT' && /^(submit|button|reset)$/i.test(n.type) && clean(n.value)) out.push({ text: clean(n.value), section, shared: sharedOf(n), attr: 'value' });
|
|
426
|
+
}
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const t = clean(n.nodeValue);
|
|
430
|
+
if (!t || !textShown(n)) continue;
|
|
431
|
+
out.push({ text: t, section, shared: sharedOf(n.parentElement) });
|
|
432
|
+
}
|
|
433
|
+
let branding = null;
|
|
434
|
+
try { branding = window.__BRANDING__ || null; } catch { branding = null; }
|
|
435
|
+
return { strings: out, branding };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Every render the reader makes for one page: the states of its states files, in
|
|
439
|
+
// file order, or one "default" render at its own path when it has none.
|
|
440
|
+
function planRenders(page, { root = REPO_ROOT } = {}) {
|
|
441
|
+
if (!page.states_files || !page.states_files.length) {
|
|
442
|
+
return [{ page: page.id, state: 'default', url: page.path, auth: page.surface !== 'landing', actions: [], stub: {}, mode: 'light', modeQuery: false }];
|
|
443
|
+
}
|
|
444
|
+
const plans = [];
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
for (const rel of page.states_files) {
|
|
447
|
+
const json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
448
|
+
const mode = (json.modes && json.modes.includes('light')) || !json.modes ? 'light' : json.modes[0];
|
|
449
|
+
for (const [name, st] of Object.entries(json.states || {})) {
|
|
450
|
+
const state = seen.has(name) ? `${path.basename(rel, '.states.json')}:${name}` : name;
|
|
451
|
+
seen.add(name);
|
|
452
|
+
plans.push({
|
|
453
|
+
page: page.id, state, url: st.url || json.page, auth: !!st.auth, actions: st.actions || [],
|
|
454
|
+
stub: json.stub || {}, mode, modeQuery: json.modeQuery, ignore: json.ignoreRequests || [],
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return plans;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const DETACH = process.platform !== 'win32';
|
|
462
|
+
function killTree(child) {
|
|
463
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
464
|
+
try {
|
|
465
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
466
|
+
else process.kill(-child.pid, 'SIGKILL');
|
|
467
|
+
} catch { /* already gone */ }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// The hall-preview harness, with the pinned fixture account (--fixture-me), so
|
|
471
|
+
// two runs on two machines read the same hall.
|
|
472
|
+
async function startHarness(kit) {
|
|
473
|
+
const port = await kit.freePort();
|
|
474
|
+
const proc = spawn(process.execPath, [path.join(REPO_ROOT, 'scripts', 'hall-preview', 'server.js'), '--port', String(port), '--fixture-me'], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], detached: DETACH });
|
|
475
|
+
await new Promise((resolve, reject) => {
|
|
476
|
+
let said = '', ready = false;
|
|
477
|
+
const timer = setTimeout(() => { killTree(proc); reject(new Error(`the hall-preview harness did not start within 30s: ${said.trim()}`)); }, 30000);
|
|
478
|
+
const hear = (d) => { if (ready) return; said += d; if (/hall-preview: http/.test(said)) { ready = true; clearTimeout(timer); resolve(); } };
|
|
479
|
+
proc.stdout.on('data', hear); // read for the whole run: an unread pipe fills and stalls the harness
|
|
480
|
+
proc.stderr.on('data', hear);
|
|
481
|
+
proc.on('exit', (code) => { if (!ready) { clearTimeout(timer); reject(new Error(`the hall-preview harness exited ${code}: ${said.trim()}`)); } });
|
|
482
|
+
});
|
|
483
|
+
return { base: `http://localhost:${port}`, stop: () => killTree(proc) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const SURFACE_MODULE = { landing: 'public-landing', status: 'status-ui' };
|
|
487
|
+
|
|
488
|
+
async function renderOne(browser, kit, plan, base) {
|
|
489
|
+
const ctx = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, colorScheme: plan.mode });
|
|
490
|
+
const notes = [];
|
|
491
|
+
const data = new Set();
|
|
492
|
+
const pending = [];
|
|
493
|
+
try {
|
|
494
|
+
if (ctx.clock && typeof ctx.clock.setFixedTime === 'function') await ctx.clock.setFixedTime(new Date(PINNED_CLOCK));
|
|
495
|
+
const page = await ctx.newPage();
|
|
496
|
+
// A states file's click on an element this fixture world never draws would
|
|
497
|
+
// otherwise wait out Playwright's 30s default; the reading notes it and moves on.
|
|
498
|
+
page.setDefaultTimeout(ACTION_TIMEOUT_MS);
|
|
499
|
+
// A native confirm()/alert() a state's click opens would hold the page; the
|
|
500
|
+
// reading is of the page, so the dialog is dismissed.
|
|
501
|
+
page.on('dialog', (d) => { d.dismiss().catch(() => {}); });
|
|
502
|
+
page.on('response', (r) => {
|
|
503
|
+
if (!/\/api\//.test(r.url())) return;
|
|
504
|
+
if (!/json/i.test(r.headers()['content-type'] || '')) return;
|
|
505
|
+
pending.push(r.json().then((j) => collectStrings(j, data)).catch(() => {}));
|
|
506
|
+
});
|
|
507
|
+
const url = kit.withMode(plan.url, plan.mode, plan.modeQuery);
|
|
508
|
+
await page.goto(base + url, { waitUntil: 'load', timeout: 45000 });
|
|
509
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
510
|
+
await page.waitForTimeout(kit.SETTLE_MS + EXTRA_SETTLE_MS);
|
|
511
|
+
try { await kit.runActions(page, plan.actions); } catch (e) { notes.push(`${plan.state}: an action failed (${String(e.message).split('\n')[0].slice(0, 120)}); read as it stood`); }
|
|
512
|
+
// A body read on a response from a page that has since navigated away never
|
|
513
|
+
// settles, so the wait for the data is bounded.
|
|
514
|
+
await Promise.race([Promise.all(pending), new Promise((r) => setTimeout(r, 2000))]);
|
|
515
|
+
const got = await page.evaluate(captureInPage);
|
|
516
|
+
if (got.branding) collectStrings(got.branding, data);
|
|
517
|
+
return { state: plan.state, strings: got.strings, notes, data };
|
|
518
|
+
} catch (e) {
|
|
519
|
+
notes.push(`${plan.state}: not rendered (${String(e.message).split('\n')[0].slice(0, 160)})`);
|
|
520
|
+
return { state: plan.state, strings: [], notes, data };
|
|
521
|
+
} finally {
|
|
522
|
+
await ctx.close().catch(() => {});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function readPages(inventory, ids, { log = console.log, progress = true } = {}) {
|
|
527
|
+
const kit = require('../../modules/ui-design/kit/lib');
|
|
528
|
+
const registry = readJsonFile(REGISTRY_REL);
|
|
529
|
+
const matcher = buildMatcher(registry);
|
|
530
|
+
const pages = inventory.pages.filter((p) => ids.includes(p.id));
|
|
531
|
+
const surfaceDir = new Map(inventory.surfaces.map((s) => [s.id, s.at]));
|
|
532
|
+
const jobs = pages.flatMap((p) => planRenders(p).map((plan, i) => ({ ...plan, order: i })));
|
|
533
|
+
const stubs = new Map();
|
|
534
|
+
const stoppers = [];
|
|
535
|
+
let harness = null;
|
|
536
|
+
let browser;
|
|
537
|
+
try {
|
|
538
|
+
browser = await kit.launchBrowser();
|
|
539
|
+
} catch (e) {
|
|
540
|
+
const err = new Error(`page-reader: ${e.message}`);
|
|
541
|
+
err.noBrowser = e instanceof kit.NoBrowserError;
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
const baseFor = async (plan, surface) => {
|
|
545
|
+
if (surface === 'builders') {
|
|
546
|
+
if (!harness) { harness = await startHarness(kit); stoppers.push(harness.stop); }
|
|
547
|
+
return harness.base;
|
|
548
|
+
}
|
|
549
|
+
const opts = { surface: SURFACE_MODULE[surface] || surface, auth: plan.auth, prefix: plan.stub.prefix, child: plan.stub.child, fixtures: plan.stub.fixtures };
|
|
550
|
+
const k = JSON.stringify(opts);
|
|
551
|
+
if (!stubs.has(k)) stubs.set(k, kit.startStub(opts).then((s) => { stoppers.push(s.stop); return s; }));
|
|
552
|
+
return (await stubs.get(k)).base;
|
|
553
|
+
};
|
|
554
|
+
const results = new Map();
|
|
555
|
+
const byId = new Map(pages.map((p) => [p.id, p]));
|
|
556
|
+
try {
|
|
557
|
+
let next = 0, done = 0;
|
|
558
|
+
const worker = async () => {
|
|
559
|
+
while (next < jobs.length) {
|
|
560
|
+
const job = jobs[next];
|
|
561
|
+
next += 1;
|
|
562
|
+
const page = byId.get(job.page);
|
|
563
|
+
const base = await baseFor(job, page.surface);
|
|
564
|
+
const t0 = Date.now();
|
|
565
|
+
// A hard ceiling per render: a page that never settles is a note on its
|
|
566
|
+
// reading, never a run that hangs.
|
|
567
|
+
let timer;
|
|
568
|
+
const r = await Promise.race([
|
|
569
|
+
renderOne(browser, kit, job, base),
|
|
570
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve({ state: job.state, strings: [], notes: [`${job.state}: not rendered (no answer within ${RENDER_CEILING_MS / 1000}s)`], data: new Set() }), RENDER_CEILING_MS); }),
|
|
571
|
+
]);
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
done += 1;
|
|
574
|
+
if (progress) log(` [${done}/${jobs.length}] ${job.page} ${job.state} (${((Date.now() - t0) / 1000).toFixed(1)}s)${r.notes.length ? ' !' : ''}`);
|
|
575
|
+
if (!results.has(job.page)) results.set(job.page, []);
|
|
576
|
+
results.get(job.page).push({ ...r, order: job.order });
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, worker));
|
|
580
|
+
} finally {
|
|
581
|
+
await browser.close().catch(() => {});
|
|
582
|
+
for (const stop of stoppers) stop();
|
|
583
|
+
}
|
|
584
|
+
const out = [];
|
|
585
|
+
for (const page of pages) {
|
|
586
|
+
const caps = (results.get(page.id) || []).sort((a, b) => a.order - b.order);
|
|
587
|
+
const data = new Set(caps.flatMap((c) => [...c.data]));
|
|
588
|
+
const reading = assembleReading(page, caps, matcher, { dataValues: data, surfaceDir: surfaceDir.get(page.surface) });
|
|
589
|
+
log(` ${page.id}: ${reading.counts.lines} lines (${reading.counts.placed} placed, ${reading.counts.shared} shared, ${reading.counts.unplaced} unplaced) from ${caps.length} state(s)${reading.notes.length ? ` ! ${reading.notes.length} note(s)` : ''}`);
|
|
590
|
+
out.push(reading);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Merge fresh readings into the committed ones, in inventory order; a page the
|
|
596
|
+
// inventory no longer lists drops out.
|
|
597
|
+
function mergeReadings(inventory, previous, fresh) {
|
|
598
|
+
const byId = new Map(((previous && previous.pages) || []).map((p) => [p.id, p]));
|
|
599
|
+
for (const r of fresh) byId.set(r.id, r);
|
|
600
|
+
return inventory.pages.map((p) => byId.get(p.id)).filter(Boolean);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function loadPrevious() {
|
|
604
|
+
try { return readJsonFile(READINGS_REL); } catch { return null; }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function main(args) {
|
|
608
|
+
const inventory = readJsonFile(INVENTORY_REL);
|
|
609
|
+
if (args.includes('--check')) {
|
|
610
|
+
const stale = checkReadings(inventory, loadPrevious());
|
|
611
|
+
if (stale.length) {
|
|
612
|
+
console.error(`page-reader: STALE — ${stale.length} page reading(s) do not match the tree:`);
|
|
613
|
+
for (const s of stale) console.error(` ✗ ${s.id} (${s.why})`);
|
|
614
|
+
console.error(`Re-read them (opens a browser): ${REREAD_COMMAND}`);
|
|
615
|
+
return 1;
|
|
616
|
+
}
|
|
617
|
+
console.log(`page-reader: all ${inventory.pages.length} page readings match their files.`);
|
|
618
|
+
return 0;
|
|
619
|
+
}
|
|
620
|
+
const i = args.indexOf('--page');
|
|
621
|
+
let ids;
|
|
622
|
+
if (i > -1) {
|
|
623
|
+
ids = String(args[i + 1] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
624
|
+
const unknown = ids.filter((id) => !inventory.pages.some((p) => p.id === id));
|
|
625
|
+
if (!ids.length || unknown.length) { console.error(`page-reader: --page needs inventory page ids${unknown.length ? ` (unknown: ${unknown.join(', ')})` : ''}`); return 2; }
|
|
626
|
+
} else if (args.includes('--stale')) {
|
|
627
|
+
ids = [...new Set(checkReadings(inventory, loadPrevious()).map((s) => s.id))].filter((id) => inventory.pages.some((p) => p.id === id));
|
|
628
|
+
if (!ids.length) {
|
|
629
|
+
const prev = loadPrevious();
|
|
630
|
+
const merged = mergeReadings(inventory, prev, []);
|
|
631
|
+
if (prev && merged.length !== ((prev.pages || []).length)) fs.writeFileSync(path.join(REPO_ROOT, READINGS_REL), serialize(buildDoc(merged)));
|
|
632
|
+
console.log('page-reader: nothing stale — no page re-read.');
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
ids = inventory.pages.map((p) => p.id);
|
|
637
|
+
}
|
|
638
|
+
const started = Date.now();
|
|
639
|
+
console.log(`page-reader: reading ${ids.length} page(s)…`);
|
|
640
|
+
let fresh;
|
|
641
|
+
try { fresh = await readPages(inventory, ids); } catch (e) {
|
|
642
|
+
console.error(e.message);
|
|
643
|
+
return e.noBrowser ? 3 : 1;
|
|
644
|
+
}
|
|
645
|
+
const secs = Math.round((Date.now() - started) / 1000);
|
|
646
|
+
if (args.includes('--print')) {
|
|
647
|
+
for (const r of fresh) {
|
|
648
|
+
console.log(`\n# ${r.id} — ${r.title} (reading ${r.reading_hash}, files ${r.files_hash})`);
|
|
649
|
+
let sec = null;
|
|
650
|
+
for (const l of r.lines) {
|
|
651
|
+
if (l.section !== sec) { sec = l.section; console.log(`## ${sec}`); }
|
|
652
|
+
console.log(` ${l.key} [${l.placement}${l.file ? ` ${l.file}:${l.line}` : ''}${l.state ? ` @${l.state}` : ''}] ${l.text}`);
|
|
653
|
+
}
|
|
654
|
+
for (const n of r.notes) console.log(` ! ${n}`);
|
|
655
|
+
}
|
|
656
|
+
console.log(`\npage-reader: read in ${secs}s; --print writes nothing.`);
|
|
657
|
+
return 0;
|
|
658
|
+
}
|
|
659
|
+
const merged = mergeReadings(inventory, loadPrevious(), fresh);
|
|
660
|
+
fs.writeFileSync(path.join(REPO_ROOT, READINGS_REL), serialize(buildDoc(merged)));
|
|
661
|
+
console.log(`page-reader: ${fresh.length} page(s) read in ${secs}s -> wrote ${READINGS_REL} (${merged.length} readings)`);
|
|
662
|
+
return 0;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (require.main === module) {
|
|
666
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`page-reader: ${e.stack || e.message}`); process.exitCode = 1; });
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
module.exports = {
|
|
670
|
+
INVENTORY_REL, REGISTRY_REL, READINGS_REL, REREAD_COMMAND, PLACEMENTS, HOLE,
|
|
671
|
+
filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
|
|
672
|
+
mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
|
|
673
|
+
captureInPage, mergeReadings,
|
|
674
|
+
};
|
|
@@ -35,6 +35,8 @@ const STEPS = [
|
|
|
35
35
|
{ key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
|
|
36
36
|
{ key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
|
|
37
37
|
{ key: 'page-inventory', label: 'docs/page-inventory.json fresh (every page + its stable id)', argv: ['scripts/gds/page-inventory.js', '--check'], fix: 'node scripts/gds/page-inventory.js', optional: true },
|
|
38
|
+
// The page readings (task 1004315, ADR 0341 D3): files_hash only, no browser; the fix is the reader's own re-read.
|
|
39
|
+
{ key: 'page-readings', label: 'docs/page-readings.json fresh (every page\'s reading matches its files)', argv: ['scripts/gds/page-reader.js', '--check'], fix: 'node scripts/gds/page-reader.js --stale', optional: true },
|
|
38
40
|
{ key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
|
|
39
41
|
{ key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
|
|
40
42
|
{ key: 'render-fit', label: 'rendered text fit + sibling overlap on the pages this branch changed (advisory)', argv: ['scripts/gds/render-check.js'], fix: 'look at the shots it names; fix the element, or say in the ship notes why it is right', advisory: true, optional: true, skipWith: '--no-render', slow: true },
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1057'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/page_reader.mjs — the page reader (BV2.TW04, task 1004315; ADR 0341 D3;
|
|
2
|
+
// the Tweak Mode chain, goal 1000095).
|
|
3
|
+
//
|
|
4
|
+
// What these tests defend:
|
|
5
|
+
// 1. NOTHING IS DROPPED. A visible string the copy registry cannot place is
|
|
6
|
+
// LISTED as unplaced, with a key and a section — the task's own done-when.
|
|
7
|
+
// Only a string that is nothing but the page's own data is left out, and
|
|
8
|
+
// the registry is always asked first, so a placed string is never mistaken
|
|
9
|
+
// for data.
|
|
10
|
+
// 2. PLACEMENT. Exact text, a holed row's pattern, a segment of a holed row —
|
|
11
|
+
// a row in the page's own files first, never another page's html.
|
|
12
|
+
// 3. SHARED. Shell text is marked shared, keeps its registry row when it has
|
|
13
|
+
// one, and is outside reading_hash.
|
|
14
|
+
// 4. THE CONTRACT. docs/page-readings.json covers every inventory page, each
|
|
15
|
+
// line carries key/section/text/placement, keys run L0001.. in order, and
|
|
16
|
+
// both hashes are what the generator computes.
|
|
17
|
+
// 5. FRESHNESS WITHOUT A BROWSER. --check compares files_hash, names the re-read
|
|
18
|
+
// command, and never loads the browser driver.
|
|
19
|
+
//
|
|
20
|
+
// DB-free, network-free, browser-free.
|
|
21
|
+
// Run: node --test tests/page_reader.mjs
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const reader = require(path.join(ROOT, 'scripts', 'gds', 'page-reader.js'));
|
|
34
|
+
const shipCheck = require(path.join(ROOT, 'scripts', 'gds', 'ship-check.js'));
|
|
35
|
+
|
|
36
|
+
const REGISTRY = {
|
|
37
|
+
entries: [
|
|
38
|
+
{ id: 'aaa000000001', text: 'Welcome back', file: 'mods/pub/home.html', line: 10 },
|
|
39
|
+
{ id: 'aaa000000002', text: '${…} of ${…} shipped this week', file: 'mods/pub/home.js', line: 40 },
|
|
40
|
+
{ id: 'aaa000000003', text: 'Signed in as ${…}. Sign out', file: 'mods/pub/shell.js', line: 7 },
|
|
41
|
+
{ id: 'aaa000000004', text: 'Welcome back', file: 'mods/pub/other.html', line: 3 },
|
|
42
|
+
{ id: 'aaa000000005', text: 'Only on the other page', file: 'mods/pub/other.html', line: 5 },
|
|
43
|
+
{ id: 'aaa000000006', text: 'Loaded late', file: 'mods/pub/late.js', line: 2 },
|
|
44
|
+
{ id: 'aaa000000007', text: 'Studio — {{worldName}}', file: 'mods/pub/home.html', line: 1 },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const PAGE = { id: 'builders:home', surface: 'builders', title: 'Home', files: ['mods/pub/home.html', 'mods/pub/shell.js', 'mods/pub/home.js'] };
|
|
48
|
+
const matcher = reader.buildMatcher(REGISTRY);
|
|
49
|
+
const place = (t) => reader.placeText(t, matcher, { files: PAGE.files, surfaceDir: 'mods/pub' });
|
|
50
|
+
const fakeFs = { readFileSync: () => 'x' };
|
|
51
|
+
const assemble = (captures, opts = {}) => reader.assembleReading(PAGE, captures, matcher, { surfaceDir: 'mods/pub', fsImpl: fakeFs, ...opts });
|
|
52
|
+
|
|
53
|
+
test('an unplaced string is listed, not dropped', () => {
|
|
54
|
+
const r = assemble([{ state: 'default', strings: [
|
|
55
|
+
{ text: 'Welcome back', section: 'Home', shared: null },
|
|
56
|
+
{ text: 'A sentence no file in the registry holds', section: 'Home', shared: null },
|
|
57
|
+
{ text: 'Welcome back', section: 'Later', shared: null },
|
|
58
|
+
] }]);
|
|
59
|
+
const line = r.lines.find((l) => l.text === 'A sentence no file in the registry holds');
|
|
60
|
+
assert.ok(line, 'the unplaceable string is in the reading');
|
|
61
|
+
assert.equal(line.placement, 'unplaced');
|
|
62
|
+
assert.equal(line.section, 'Home');
|
|
63
|
+
assert.match(line.key, /^L\d{4}$/);
|
|
64
|
+
assert.equal(line.file, undefined, 'an unplaced line names no file');
|
|
65
|
+
assert.equal(r.counts.unplaced, 1);
|
|
66
|
+
assert.deepEqual(r.lines.map((l) => l.key), ['L0001', 'L0002', 'L0003'], 'keys run in page order');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an unplaced string that CONTAINS data keeps its words, with the data holed', () => {
|
|
70
|
+
const data = new Set(['Ada Lovelace', 'ready']);
|
|
71
|
+
const r = assemble([{ state: 'default', strings: [
|
|
72
|
+
{ text: 'Good morning, Ada Lovelace', section: '', shared: null },
|
|
73
|
+
{ text: 'Ada Lovelace', section: '', shared: null },
|
|
74
|
+
{ text: '42', section: '', shared: null },
|
|
75
|
+
] }], { dataValues: data });
|
|
76
|
+
assert.deepEqual(r.lines.map((l) => [l.text, l.placement]), [['Good morning, {…}', 'unplaced']]);
|
|
77
|
+
assert.equal(r.omitted.data, 1, 'the pure-data string is counted, and the number is not copy');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('clock times and dates in an unplaced string are holed, so a re-read reads the same', () => {
|
|
81
|
+
assert.equal(reader.holeData('· since 10:43 PM', new Set()).text, '· since {…}');
|
|
82
|
+
assert.equal(reader.holeData('Filed Sep 10, 2026 · Blocking 2 tasks:', new Set()).text, 'Filed {…} · Blocking 2 tasks:');
|
|
83
|
+
assert.equal(reader.holeData('Jul 20, 2026', new Set()).data, true, 'a bare date is data');
|
|
84
|
+
assert.equal(reader.holeData('Licensed AGPL-3.0', new Set()).text, 'Licensed AGPL-3.0', 'a version number is left alone');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('the registry is asked before the data: a placed string is never taken for data', () => {
|
|
88
|
+
const r = assemble([{ state: 'default', strings: [{ text: 'Welcome back', section: '', shared: null }] }], { dataValues: new Set(['Welcome back']) });
|
|
89
|
+
assert.equal(r.lines.length, 1);
|
|
90
|
+
assert.equal(r.lines[0].placement, 'placed');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('placement: exact, pattern and segment; the page\'s own file wins; never another page\'s html', () => {
|
|
94
|
+
assert.deepEqual(place('Welcome back'), { file: 'mods/pub/home.html', line: 10, string_id: 'aaa000000001', text: 'Welcome back', how: 'exact' });
|
|
95
|
+
const pat = place('3 of 9 shipped this week');
|
|
96
|
+
assert.equal(pat.string_id, 'aaa000000002');
|
|
97
|
+
assert.equal(pat.text, '{…} of {…} shipped this week', 'a pattern-placed line reads as its row, with the registry\'s {…} holes');
|
|
98
|
+
assert.equal(place('shipped this week').string_id, 'aaa000000002', 'a literal segment of a holed row places');
|
|
99
|
+
assert.equal(place('Only on the other page'), null, 'a row in ANOTHER page\'s html does not place a string on this one');
|
|
100
|
+
assert.equal(place('Loaded late').file, 'mods/pub/late.js', 'a script elsewhere on the surface is the fallback');
|
|
101
|
+
assert.equal(place('Studio — Cloud Bongos').text, 'Studio — {{worldName}}', 'entities decode and a {{token}} is a hole');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('shared text is marked shared, keeps its row, and stays out of reading_hash', () => {
|
|
105
|
+
const own = { text: 'Welcome back', section: 'Home', shared: null };
|
|
106
|
+
const a = assemble([{ state: 'default', strings: [{ text: 'Signed in as Ada. Sign out', section: '', shared: 'top bar' }, own] }]);
|
|
107
|
+
const top = a.lines[0];
|
|
108
|
+
assert.equal(top.placement, 'shared');
|
|
109
|
+
assert.equal(top.section, 'top bar');
|
|
110
|
+
assert.equal(top.string_id, 'aaa000000003');
|
|
111
|
+
const b = assemble([{ state: 'default', strings: [{ text: 'Something else entirely', section: '', shared: 'top bar' }, own] }]);
|
|
112
|
+
assert.equal(b.lines[0].placement, 'shared', 'an unplaceable shell string is still shared');
|
|
113
|
+
assert.equal(a.reading_hash, b.reading_hash, 'a changed shell line does not move the page\'s own hash');
|
|
114
|
+
const c = assemble([{ state: 'default', strings: [{ text: 'Welcome back', section: 'Elsewhere', shared: null }] }]);
|
|
115
|
+
assert.notEqual(a.reading_hash, c.reading_hash, 'a changed own line does');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('other states add their lines where they sit, named, after the default state\'s', () => {
|
|
119
|
+
const r = assemble([
|
|
120
|
+
{ state: 'closed', strings: [{ text: 'Welcome back', section: 'Home', shared: null }, { text: 'Loaded late', section: 'Home', shared: null }] },
|
|
121
|
+
{ state: 'open', strings: [{ text: 'Welcome back', section: 'Home', shared: null }, { text: 'A panel only open shows', section: 'Home', shared: null }, { text: 'Loaded late', section: 'Home', shared: null }] },
|
|
122
|
+
]);
|
|
123
|
+
assert.deepEqual(r.lines.map((l) => [l.text, l.state || null]), [['Welcome back', null], ['A panel only open shows', 'open'], ['Loaded late', null]]);
|
|
124
|
+
assert.deepEqual(r.states_read, ['closed', 'open']);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a string repeated in one section is one line with its occurrences', () => {
|
|
128
|
+
const r = assemble([{ state: 'default', strings: [1, 2, 3].map(() => ({ text: 'Welcome back', section: 'List', shared: null })) }]);
|
|
129
|
+
assert.equal(r.lines.length, 1);
|
|
130
|
+
assert.equal(r.lines[0].occurrences, 3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('files_hash folds CRLF, and moves when a file changes or disappears', () => {
|
|
134
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reader-'));
|
|
135
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\ntwo\n');
|
|
136
|
+
const lf = reader.filesHash(['a.html'], { root: dir });
|
|
137
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\r\ntwo\r\n');
|
|
138
|
+
assert.equal(reader.filesHash(['a.html'], { root: dir }), lf, 'a Windows checkout agrees with CI');
|
|
139
|
+
fs.writeFileSync(path.join(dir, 'a.html'), 'one\ntwo!\n');
|
|
140
|
+
assert.notEqual(reader.filesHash(['a.html'], { root: dir }), lf);
|
|
141
|
+
fs.rmSync(path.join(dir, 'a.html'));
|
|
142
|
+
assert.notEqual(reader.filesHash(['a.html'], { root: dir }), lf);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('checkReadings names a missing reading, a changed page and an extra one', () => {
|
|
146
|
+
const inv = { pages: [{ id: 's:a', files: ['x'] }, { id: 's:b', files: ['y'] }] };
|
|
147
|
+
const f = { readFileSync: (p) => (p.endsWith('x') ? 'X' : 'Y') };
|
|
148
|
+
const good = reader.filesHash(['x'], { fsImpl: f });
|
|
149
|
+
const stale = reader.checkReadings(inv, { pages: [{ id: 's:a', files_hash: good }, { id: 's:gone', files_hash: 'z' }] }, { fsImpl: f });
|
|
150
|
+
assert.deepEqual(stale, [{ id: 's:b', why: 'no reading' }, { id: 's:gone', why: 'not in the inventory' }]);
|
|
151
|
+
assert.deepEqual(reader.checkReadings(inv, { pages: [{ id: 's:a', files_hash: 'old' }, { id: 's:b', files_hash: reader.filesHash(['y'], { fsImpl: f }) }] }, { fsImpl: f }), [{ id: 's:a', why: 'its files changed' }]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the committed readings cover every inventory page, in the D3 shape', () => {
|
|
155
|
+
const inv = JSON.parse(fs.readFileSync(path.join(ROOT, reader.INVENTORY_REL), 'utf8'));
|
|
156
|
+
const doc = JSON.parse(fs.readFileSync(path.join(ROOT, reader.READINGS_REL), 'utf8'));
|
|
157
|
+
assert.deepEqual(doc.pages.map((p) => p.id), inv.pages.map((p) => p.id), 'one reading per inventory page, in inventory order');
|
|
158
|
+
let unplaced = 0;
|
|
159
|
+
for (const p of doc.pages) {
|
|
160
|
+
assert.ok(p.lines.length > 0, `${p.id} has lines`);
|
|
161
|
+
p.lines.forEach((l, i) => {
|
|
162
|
+
assert.equal(l.key, `L${String(i + 1).padStart(4, '0')}`, `${p.id} keys run in order`);
|
|
163
|
+
assert.ok(typeof l.section === 'string' && l.section, `${p.id} ${l.key} has a section`);
|
|
164
|
+
assert.ok(typeof l.text === 'string' && l.text, `${p.id} ${l.key} has text`);
|
|
165
|
+
assert.ok(reader.PLACEMENTS.includes(l.placement), `${p.id} ${l.key} placement`);
|
|
166
|
+
if (l.placement === 'placed') assert.ok(l.file && Number.isInteger(l.line) && /^[0-9a-f]{12}$/.test(l.string_id), `${p.id} ${l.key} names its registry row`);
|
|
167
|
+
if (l.placement === 'unplaced') { unplaced += 1; assert.equal(l.string_id, undefined); }
|
|
168
|
+
});
|
|
169
|
+
assert.equal(p.reading_hash, reader.readingHash(p.lines), `${p.id} reading_hash is over its lines`);
|
|
170
|
+
assert.equal(p.counts.lines, p.lines.length);
|
|
171
|
+
}
|
|
172
|
+
assert.ok(unplaced > 0, 'the real pages carry unplaced strings, and they are listed');
|
|
173
|
+
assert.deepEqual(reader.checkReadings(inv, doc), [], 'the committed readings match this tree (re-read: node scripts/gds/page-reader.js --stale)');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('--check needs no browser and names the re-read command', () => {
|
|
177
|
+
// A preload that makes the browser driver unloadable: if --check ever reached
|
|
178
|
+
// for it, this run would fail.
|
|
179
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-reader-nobrowser-'));
|
|
180
|
+
const pre = path.join(dir, 'no-browser.cjs');
|
|
181
|
+
fs.writeFileSync(pre, "const M=require('module');const o=M._load;M._load=function(r,...a){if(/playwright|ui-design[\\\\/]kit/.test(r))throw new Error('browser reached: '+r);return o.call(this,r,...a);};\n");
|
|
182
|
+
const r = spawnSync(process.execPath, ['--require', pre, path.join(ROOT, 'scripts', 'gds', 'page-reader.js'), '--check'], { cwd: ROOT, encoding: 'utf8' });
|
|
183
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
184
|
+
assert.match(reader.REREAD_COMMAND, /page-reader\.js --stale/);
|
|
185
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'page-reader.js'), 'utf8');
|
|
186
|
+
assert.match(src, /Re-read them \(opens a browser\): \$\{REREAD_COMMAND\}/, 'a stale --check names the command');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('ship-check runs the browser-free --check, and fitness does not render', () => {
|
|
190
|
+
const step = shipCheck.STEPS.find((s) => s.key === 'page-readings');
|
|
191
|
+
assert.ok(step, 'ship-check has a page-readings row');
|
|
192
|
+
assert.deepEqual(step.argv, ['scripts/gds/page-reader.js', '--check']);
|
|
193
|
+
assert.equal(step.fix, reader.REREAD_COMMAND);
|
|
194
|
+
const fitness = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'fitness.js'), 'utf8');
|
|
195
|
+
assert.doesNotMatch(fitness, /page-reader/, 'fitness never runs the reader (a render is the builder\'s own command)');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('a hall page with no states file is read once, at its own path', () => {
|
|
199
|
+
assert.deepEqual(reader.planRenders({ id: 'builders:x', surface: 'builders', path: '/x', states_files: [] }).map((p) => [p.state, p.url]), [['default', '/x']]);
|
|
200
|
+
});
|