@bongos/core 1.19.1055 → 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.
@@ -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.1055'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
+ });