@bongos/core 1.19.1054 → 1.19.1055

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 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.1054",
6
- "core_contract": "1.19.1054",
7
- "source_commit": "16cad90499c802cd308487cec2c824589b064b95",
5
+ "core_version": "1.19.1055",
6
+ "core_contract": "1.19.1055",
7
+ "source_commit": "dc38a2a4e86d707edd38427cf7df3c4437458758",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-28T04:35:28.546Z",
9
+ "built_at": "2026-09-28T05:05:07.855Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 542,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2509,
14
+ "functional_verbatim": 2511,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3078,
20
- "tree_sha256": "b4fe4ba91ff03f9db59c7306f72e4f1898f4f5d0a168d05a228e0689c42765db",
19
+ "file_count": 3080,
20
+ "tree_sha256": "b6c500e72b0b49f4521c50a3282616cfdf968b85b827eace71deb76fca3f55dd",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2727,7 +2727,7 @@
2727
2727
  {
2728
2728
  "path": "docs/file-map.md",
2729
2729
  "mode": "0000644",
2730
- "sha256": "64d65a849596466c7fbafba381668129c2e305e88af805dc5eed06a5b06600bb"
2730
+ "sha256": "fe02a043a43ec6c062efea669b07722ae08e5ef93555ca80e489504dbc0180ad"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/handoff-template.md",
@@ -2737,7 +2737,7 @@
2737
2737
  {
2738
2738
  "path": "docs/module-api-changelog.md",
2739
2739
  "mode": "0000644",
2740
- "sha256": "cd63efa0d5860b1d1e150d0472ea95cc8285e58526aae6696f05dc803ca7cfea"
2740
+ "sha256": "5a093f82bbd86f7932a798a5e53f85609e08c7837ef64c2334a7a2b8efc401c1"
2741
2741
  },
2742
2742
  {
2743
2743
  "path": "docs/modules-contract.md",
@@ -8462,12 +8462,12 @@
8462
8462
  {
8463
8463
  "path": "package-lock.json",
8464
8464
  "mode": "0000644",
8465
- "sha256": "7a222030251e4f1975d78ef3b4bf78d212aa17b80d001b6858f0dcf92bbf0d90"
8465
+ "sha256": "b60b03c560a09917e3383f0630839ff29ffade876f87f0079f1cb718dac762e6"
8466
8466
  },
8467
8467
  {
8468
8468
  "path": "package.json",
8469
8469
  "mode": "0000644",
8470
- "sha256": "b4cf0e08cfe3dc1e5980144ff698d22ac1800712e3939a663d67845232d0b95c"
8470
+ "sha256": "21389dd422886a6fcf8088ac5be8dd63f39e76f6c28fd063f3cd72e9eadefc2c"
8471
8471
  },
8472
8472
  {
8473
8473
  "path": "public-docs/index.html",
@@ -8487,7 +8487,7 @@
8487
8487
  {
8488
8488
  "path": "release-notes.json",
8489
8489
  "mode": "0000644",
8490
- "sha256": "b4125b6b8f4b390e216f8686281a0a98da09c2a405f662beb8368ef6dee81ce3"
8490
+ "sha256": "3631f0c336ffb1fa2da262932c3158a4cb79c640c52ffc04e08a21b23c8f4534"
8491
8491
  },
8492
8492
  {
8493
8493
  "path": "scripts/bongos-mcp.js",
@@ -9062,7 +9062,7 @@
9062
9062
  {
9063
9063
  "path": "scripts/gds/fitness.js",
9064
9064
  "mode": "0000644",
9065
- "sha256": "4e698cceb33eeea11fdab35cf6bf878b7f12bd1057f79c6ca8b0740d45e80e68"
9065
+ "sha256": "6ea5ed4e24c83e50d3931e3a5a769b9f37d029627540d410e772200d5cc3652d"
9066
9066
  },
9067
9067
  {
9068
9068
  "path": "scripts/gds/gate-review.js",
@@ -9384,6 +9384,11 @@
9384
9384
  "mode": "0000644",
9385
9385
  "sha256": "aa29af97270a614699e5480e0db5b7bf34034958cc28e92bee5a04f7c5c95bec"
9386
9386
  },
9387
+ {
9388
+ "path": "scripts/gds/page-inventory.js",
9389
+ "mode": "0000644",
9390
+ "sha256": "f4b676c3949015733bc3f1bcaf369c2ab1225583b0200f862c25a6f0f18d3873"
9391
+ },
9387
9392
  {
9388
9393
  "path": "scripts/gds/paste-token.js",
9389
9394
  "mode": "0000644",
@@ -9842,7 +9847,7 @@
9842
9847
  {
9843
9848
  "path": "scripts/gds/ship-check.js",
9844
9849
  "mode": "0000644",
9845
- "sha256": "40322d95339d20fd0ab0dcf6bd528736b815d895b31ca6a54490fb31c769a480"
9850
+ "sha256": "3ca2e32eaea17ef03a91606b3815551a8cfdd3987b6e182c08017c84c7947ee3"
9846
9851
  },
9847
9852
  {
9848
9853
  "path": "scripts/gds/ship-deploy-target.js",
@@ -10562,7 +10567,7 @@
10562
10567
  {
10563
10568
  "path": "src/module-api.js",
10564
10569
  "mode": "0000644",
10565
- "sha256": "b31a0374ebfe190599d53bfd40004328cc7bb1aa3906487c005a53718b990a21"
10570
+ "sha256": "22a1cf07ea07585df430c3a395af379d9a6bb81b20d5bf647128272568b57580"
10566
10571
  },
10567
10572
  {
10568
10573
  "path": "src/module-loader/catalog.js",
@@ -13629,6 +13634,11 @@
13629
13634
  "mode": "0000644",
13630
13635
  "sha256": "3b24dc37c492b248242568db7dd9d508e17e05aa746bb719952b8a06a58c395f"
13631
13636
  },
13637
+ {
13638
+ "path": "tests/page_inventory.mjs",
13639
+ "mode": "0000644",
13640
+ "sha256": "363855afde70c909be7b6392d36663848c823afed0131a8785286b7b93ca321b"
13641
+ },
13632
13642
  {
13633
13643
  "path": "tests/parallel-load.mjs",
13634
13644
  "mode": "0000644",
package/docs/file-map.md CHANGED
@@ -48,6 +48,8 @@
48
48
  │ ├── file-map.notes.json ← ADR 0066 ([#1276](https://example.com/builders#/task/1276)): the sidecar of one-line notes for each skill/routine that gen-file-map.js renders into file-map.md; the ONE place to edit those notes — a new skill with no note here fails CI
49
49
  │ ├── copy-registry.json ← GENERATED (scripts/gds/copy-inventory.js): the machine-readable copy registry — 1,096 user-facing strings with stable ids, render paths, and duplicates. R02's flag queue attaches to these ids; regenerate + commit, never hand-edit
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
+ │ ├── 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
+ │ ├── page-inventory.md ← GENERATED twin of page-inventory.json: the id rule, then one table per surface
51
53
  │ ├── handoff-template.md ← session-end template + Definition of Done
52
54
  │ ├── 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
53
55
  │ ├── google-chat-broadcast.md ← team-chat integration plan
@@ -199,6 +201,7 @@
199
201
  │ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
200
202
  │ ├── 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
201
203
  │ ├── 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
+ │ ├── 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
202
205
  │ ├── 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
203
206
  │ ├── 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
204
207
  │ ├── 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
@@ -2595,5 +2595,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2595
2595
  landed since 1.19.1052 with no explicit bump. run 36375859084. (task 1002620)
2596
2596
  1.19.1054 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2597
2597
  landed since 1.19.1053 with no explicit bump. run 36378326588. (task 1002620)
2598
+ 1.19.1055 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2599
+ landed since 1.19.1054 with no explicit bump. run 36380323033. (task 1002620)
2598
2600
  ---------------------------------------------------------------------------
2599
2601
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1054",
3
+ "version": "1.19.1055",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1054",
9
+ "version": "1.19.1055",
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.1054",
3
+ "version": "1.19.1055",
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",
@@ -7625,5 +7625,11 @@
7625
7625
  "id": "1004334",
7626
7626
  "text": "The platform can now apply a saved setting to its own site, not just to the projects it hosts. Turning the artist gate off on Cloud Bongos will actually reach cloudbongos.com: the platform updates its own settings file and res"
7627
7627
  }
7628
+ ],
7629
+ "1.19.1055": [
7630
+ {
7631
+ "id": "1004314",
7632
+ "text": "Every page the project serves now has a name that stays put. A new generated list names all 37 pages across the builders hall (30), the landing site (5) and the status site (2). Each entry gives the page a short id such as bui"
7633
+ }
7628
7634
  ]
7629
7635
  }
@@ -243,9 +243,17 @@ function checkGeneratedArtifactsFresh() {
243
243
  } catch {
244
244
  violations.push('docs/copy-registry.json + docs/copy-inventory.md are STALE — every row carries a line number, so editing any scanned UI file shifts them. Run `node scripts/gds/copy-inventory.js` and commit (task 1003556).');
245
245
  }
246
+ try {
247
+ // task 1004314 (BV2.TW02): the page inventory is the key Tweak Mode's page status,
248
+ // claims and pay join on, so a page added, renamed or retitled without it would
249
+ // be a page the chain cannot see. --check also fails on a duplicate or malformed id.
250
+ execFileSync(process.execPath, [path.join(__dirname, 'page-inventory.js'), '--check'], { cwd: REPO_ROOT, stdio: 'pipe' });
251
+ } catch {
252
+ violations.push('docs/page-inventory.json + docs/page-inventory.md are STALE (or two pages claim one id) — a page was added, renamed, retitled or changed its scripts/states. Run `node scripts/gds/page-inventory.js` and commit (task 1004314).');
253
+ }
246
254
  // NB the role packs' responsibility blocks are generated too, but their freshness
247
255
  // is asserted by role-pack-guard.js — one check owns "the packs are correct".
248
- return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API) and docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
256
+ return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
249
257
  }
250
258
 
251
259
  // ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/page-inventory.js — the PAGE INVENTORY: every page of every web
5
+ // surface an instance serves, each with a stable id (BV2.TW02, task 1004314;
6
+ // goal 1000095, the Tweak Mode chain — docs/specs/bongos-v2-tweak-mode.md).
7
+ //
8
+ // WHY. Tweak Mode's unit is the PAGE: its status, claim, draft, changelog and pay
9
+ // all key on a page id (spec, decision 1). Until now a page was implicit — a
10
+ // body[data-page] attribute in the hall, a file name on the landing and status
11
+ // surfaces — so nothing could say "these are the 42 pages, and this is the one you
12
+ // hold". This generator makes the list explicit, and gives every page an id the
13
+ // rest of the chain (TW03 the ADR, TW04 the page reader, TW05 statuses, TW06
14
+ // claims) can store and join on.
15
+ //
16
+ // PORTABLE BY CONSTRUCTION. The surfaces are NOT a list in this file: they come
17
+ // from the module loader's moduleWebSurfaces() over the modules THIS instance has
18
+ // enabled (src/modules.js isModuleEnabled). An instance that disables its front
19
+ // door has no landing pages here; a module that ships a new web surface appears
20
+ // with no edit to this file. Same seam serve-internal.js and platform-server.js
21
+ // route from, so the inventory and the router cannot disagree about what exists.
22
+ //
23
+ // THE ID RULE (also written into both artifacts' headers — it is the contract):
24
+ // <surface>:<page>
25
+ // surface = the web surface's Host prefix without its dot ("builders." ->
26
+ // "builders", "status." -> "status"); the apex-root surface is
27
+ // "landing" (ADR 0218 allows exactly one apex root per instance).
28
+ // page = the page's own body[data-page] when it declares one (the hall's
29
+ // pages do), otherwise the html file's base name ("index.html" ->
30
+ // "index").
31
+ // Both halves are what a person already sees in an address or a file, so an id
32
+ // reads as a place: builders:studio, landing:index, status:explore. Ids are
33
+ // lowercase [a-z0-9-] on both sides; a page that would break that, or two pages
34
+ // sharing one id, fail the build by name rather than being renamed silently.
35
+ // An id changes ONLY when the host prefix, the data-page value or the file name
36
+ // changes — each a deliberate rename, and the ledger records keyed on the old id
37
+ // (TW05) must be carried across in the same change.
38
+ //
39
+ // DELIBERATELY IN THE gen-* FAMILY, modelled on copy-inventory.js: fs only (no DB,
40
+ // no network, no clock), byte-deterministic on a given tree, a committed JSON twin
41
+ // plus a human report, and a --check that fitness.js ("generated artifacts fresh")
42
+ // and ship-check.js run so drift fails before it merges.
43
+ //
44
+ // Run: node scripts/gds/page-inventory.js (writes both artifacts)
45
+ // node scripts/gds/page-inventory.js --check (exit 1 if stale or invalid)
46
+ // node scripts/gds/page-inventory.js --json (inventory to stdout, no write)
47
+
48
+ const fs = require('node:fs');
49
+ const path = require('node:path');
50
+ const { sameContent } = require('./gen-freshness'); // eol-insensitive freshness (CRLF checkouts)
51
+
52
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
53
+ // Repo-relative paths inside the committed artifacts are joined with '/', never
54
+ // path.join — a Windows run must write the same bytes CI compares against.
55
+ const INVENTORY_REL = 'docs/page-inventory.json';
56
+ const REPORT_REL = 'docs/page-inventory.md';
57
+
58
+ const ID_RULE = [
59
+ 'id = <surface>:<page>.',
60
+ 'surface = the web surface Host prefix without its dot (builders., status.) or "landing" for the apex-root surface.',
61
+ 'page = the page body[data-page] when declared, otherwise the html file base name.',
62
+ 'Both halves are lowercase [a-z0-9-]; a duplicate or malformed id fails the generator by name.',
63
+ 'An id changes only when the host prefix, the data-page value or the file name changes; that is a rename, and records keyed on the old id move with it.',
64
+ ];
65
+
66
+ const SLUG = /^[a-z0-9][a-z0-9-]*$/;
67
+
68
+ // Locale-independent order (see copy-inventory.js: localeCompare depends on ICU).
69
+ function cmp(a, b) {
70
+ if (a === b) return 0;
71
+ return a < b ? -1 : 1;
72
+ }
73
+
74
+ function toPosix(p) { return p.split(path.sep).join('/'); }
75
+
76
+ // The few entities hall titles actually use. Not a general decoder, on purpose.
77
+ function decodeEntities(s) {
78
+ return String(s)
79
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
80
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ');
81
+ }
82
+
83
+ // The surface slug, derived from the loader's descriptor (see THE ID RULE).
84
+ function surfaceSlug(ws) {
85
+ if (ws.apexRoot) return 'landing';
86
+ return String(ws.host || '').replace(/\.$/, '').toLowerCase();
87
+ }
88
+
89
+ // Where a surface answers, as a portable address template. {apex} is the
90
+ // instance's own domain; the inventory never bakes one in.
91
+ function surfaceOrigin(ws) {
92
+ return ws.apexRoot ? 'https://{apex}' : `https://${ws.host}{apex}`;
93
+ }
94
+
95
+ // The live path a page file is served at. Every surface resolves a bare slug to
96
+ // <slug>.html (the hall's buildersHtmlName, express.static extensions:['html'] on
97
+ // status, the landing routes), and index.html answers the root.
98
+ function pagePath(base) {
99
+ return base === 'index' ? '/' : `/${base}`;
100
+ }
101
+
102
+ // Read the facts a page declares about itself, from its markup alone.
103
+ function readPageMarkup(source) {
104
+ const body = source.match(/<body\b([^>]*)>/i);
105
+ const attrs = body ? body[1] : '';
106
+ const attr = (name) => {
107
+ const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, 'i'));
108
+ return m ? decodeEntities(m[1]).trim() : null;
109
+ };
110
+ const title = source.match(/<title>([^<]*)<\/title>/i);
111
+ return {
112
+ dataPage: attr('data-page'),
113
+ dataPageTitle: attr('data-page-title'),
114
+ documentTitle: title ? decodeEntities(title[1]).replace(/\s+/g, ' ').trim() : null,
115
+ };
116
+ }
117
+
118
+ // The local stylesheets and scripts a page pulls in, as repo-relative paths, in
119
+ // document order. A reference is resolved against the surface dir first, then with
120
+ // its first segment dropped (the hall addresses its assets as /builders/<file>),
121
+ // then against the repo (/modules/<key>/public/<file>, the loader's module-asset
122
+ // URL). Anything that does not resolve to a file on disk — a CDN, a font host, a
123
+ // data: URI — is not a file that renders the page here, and is left out.
124
+ function localAssets(source, surfaceDirAbs, { root = REPO_ROOT, fsImpl = fs } = {}) {
125
+ const refs = [];
126
+ const re = /<script\b[^>]*\bsrc\s*=\s*"([^"]+)"|<link\b[^>]*\brel\s*=\s*"stylesheet"[^>]*\bhref\s*=\s*"([^"]+)"|<link\b[^>]*\bhref\s*=\s*"([^"]+)"[^>]*\brel\s*=\s*"stylesheet"/gi;
127
+ let m;
128
+ while ((m = re.exec(source)) !== null) refs.push(m[1] || m[2] || m[3]);
129
+ const out = [];
130
+ for (const raw of refs) {
131
+ if (/^(?:[a-z]+:)?\/\//i.test(raw) || /^data:/i.test(raw)) continue;
132
+ const clean = raw.split(/[?#]/)[0];
133
+ if (!clean) continue;
134
+ const rel = clean.replace(/^\/+/, '');
135
+ const candidates = [
136
+ path.join(surfaceDirAbs, rel),
137
+ path.join(surfaceDirAbs, rel.split('/').slice(1).join('/')),
138
+ path.join(root, rel),
139
+ ];
140
+ const hit = candidates.find((c) => {
141
+ try { return fsImpl.statSync(c).isFile(); } catch { return false; }
142
+ });
143
+ if (!hit) continue;
144
+ const repoRel = toPosix(path.relative(root, hit));
145
+ if (!out.includes(repoRel)) out.push(repoRel);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // The .states.json sidecars for a page (the ui-design kit's convention:
151
+ // <dir>/<page>.states.json). A VARIANT file <base>-<variant>.states.json belongs to
152
+ // the page too — the landing's projects page keeps three — unless
153
+ // <base>-<variant>.html is itself a page, in which case it is that page's own.
154
+ function statesFor(base, names, surfaceDirAbs, surfaceRel) {
155
+ const pages = new Set(names.filter((n) => n.endsWith('.html')).map((n) => n.slice(0, -5)));
156
+ const files = names.filter((n) => n.endsWith('.states.json')).map((n) => n.slice(0, -'.states.json'.length))
157
+ .filter((stem) => {
158
+ if (stem === base) return true;
159
+ if (!stem.startsWith(`${base}-`)) return false;
160
+ if (pages.has(stem)) return false;
161
+ // The LONGEST page base that prefixes the stem owns it (a-b-c.states.json is
162
+ // a-b's, not a's, when a-b.html exists).
163
+ const owner = [...pages].filter((p) => stem === p || stem.startsWith(`${p}-`)).sort((x, y) => y.length - x.length)[0];
164
+ return owner === base;
165
+ })
166
+ .sort((a, b) => (a === base ? -1 : b === base ? 1 : cmp(a, b)));
167
+ return files.map((stem) => {
168
+ const abs = path.join(surfaceDirAbs, `${stem}.states.json`);
169
+ let parsed = null;
170
+ try { parsed = JSON.parse(fs.readFileSync(abs, 'utf8')); } catch (e) {
171
+ throw new Error(`page-inventory: ${surfaceRel}/${stem}.states.json is not valid JSON (${e.message})`);
172
+ }
173
+ const states = parsed && parsed.states && typeof parsed.states === 'object' ? Object.keys(parsed.states) : [];
174
+ return { file: `${surfaceRel}/${stem}.states.json`, page: typeof parsed.page === 'string' ? parsed.page : null, states };
175
+ });
176
+ }
177
+
178
+ // The enabled web surfaces, from the loader. Injected in tests.
179
+ function enabledSurfaces() {
180
+ const { moduleWebSurfaces } = require('../../src/module-loader/loader');
181
+ const { isModuleEnabled } = require('../../src/modules');
182
+ return moduleWebSurfaces({ isEnabled: isModuleEnabled });
183
+ }
184
+
185
+ function buildInventory({ surfaces = enabledSurfaces(), root = REPO_ROOT } = {}) {
186
+ const errors = [];
187
+ const pages = [];
188
+ const surfaceRows = [];
189
+ const seen = new Map();
190
+ const described = surfaces.map((ws) => ({ ws, slug: surfaceSlug(ws) }))
191
+ .sort((a, b) => cmp(a.slug, b.slug) || cmp(a.ws.key, b.ws.key));
192
+ const slugOwner = new Map();
193
+ for (const { ws, slug } of described) {
194
+ const surfaceRel = toPosix(path.relative(root, ws.dir));
195
+ if (!SLUG.test(slug)) { errors.push(`surface "${ws.key}" (${surfaceRel}) derives the id prefix "${slug}", which is not [a-z0-9-]`); continue; }
196
+ if (slugOwner.has(slug)) { errors.push(`surfaces "${slugOwner.get(slug)}" and "${ws.key}" both derive the id prefix "${slug}"`); continue; }
197
+ slugOwner.set(slug, ws.key);
198
+ const names = fs.readdirSync(ws.dir).sort(cmp);
199
+ const htmls = names.filter((n) => n.endsWith('.html'));
200
+ let count = 0;
201
+ for (const name of htmls) {
202
+ const base = name.slice(0, -5);
203
+ const rel = `${surfaceRel}/${name}`;
204
+ const source = fs.readFileSync(path.join(ws.dir, name), 'utf8');
205
+ const markup = readPageMarkup(source);
206
+ const pageSlug = markup.dataPage || base;
207
+ const id = `${slug}:${pageSlug}`;
208
+ if (!SLUG.test(pageSlug)) { errors.push(`${rel}: page "${pageSlug}" is not [a-z0-9-] (from ${markup.dataPage ? 'body[data-page]' : 'the file name'})`); continue; }
209
+ if (seen.has(id)) { errors.push(`${rel} and ${seen.get(id)} both claim the page id "${id}"`); continue; }
210
+ seen.set(id, rel);
211
+ const states = statesFor(base, names, ws.dir, surfaceRel);
212
+ pages.push({
213
+ id,
214
+ surface: slug,
215
+ module: ws.key,
216
+ page: pageSlug,
217
+ id_from: markup.dataPage ? 'data-page' : 'file-name',
218
+ title: markup.dataPageTitle || markup.documentTitle || pageSlug,
219
+ document_title: markup.documentTitle,
220
+ path: pagePath(base),
221
+ url: `${surfaceOrigin(ws)}${pagePath(base)}`,
222
+ files: [rel, ...localAssets(source, ws.dir, { root })],
223
+ states_files: states.map((s) => s.file),
224
+ states: states.flatMap((s) => s.states),
225
+ });
226
+ count += 1;
227
+ }
228
+ // A scan breaks by quietly finding nothing, and an empty surface would read as
229
+ // a clean --check. Every surface the loader returns ships a directory it serves
230
+ // pages from, so zero pages there is a scan defect, not a quiet surface.
231
+ if (count === 0 && htmls.length === 0) errors.push(`surface "${ws.key}" (${surfaceRel}) has no .html pages — a web surface with nothing to serve is a scan defect`);
232
+ surfaceRows.push({
233
+ id: slug, module: ws.key, at: surfaceRel,
234
+ answers_at: ws.apexRoot ? 'apex root' : `host prefix ${ws.host}`,
235
+ origin: surfaceOrigin(ws), pages: count,
236
+ });
237
+ }
238
+ if (errors.length) {
239
+ const err = new Error(`page-inventory: ${errors.length} page id problem(s):\n - ${errors.join('\n - ')}`);
240
+ err.problems = errors;
241
+ throw err;
242
+ }
243
+ pages.sort((a, b) => cmp(a.id, b.id));
244
+ return {
245
+ schema: 1,
246
+ generator: 'scripts/gds/page-inventory.js',
247
+ task: 1004314,
248
+ id_rule: ID_RULE,
249
+ counts: {
250
+ pages: pages.length,
251
+ surfaces: surfaceRows.length,
252
+ with_states: pages.filter((p) => p.states_files.length).length,
253
+ states: pages.reduce((n, p) => n + p.states.length, 0),
254
+ },
255
+ surfaces: surfaceRows,
256
+ pages,
257
+ };
258
+ }
259
+
260
+ function renderReport(inv) {
261
+ const L = [];
262
+ const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|');
263
+ L.push('# Page inventory — every page of every surface, with its stable id');
264
+ L.push('');
265
+ L.push('> **Generated** by `node scripts/gds/page-inventory.js` (BV2.TW02, task 1004314) from the web surfaces of the modules this instance has enabled.');
266
+ L.push('> Deterministic on a given tree — regenerate and commit rather than hand-editing. Machine-readable twin: [`page-inventory.json`](page-inventory.json).');
267
+ L.push('');
268
+ L.push('## The id rule');
269
+ L.push('');
270
+ for (const r of inv.id_rule) L.push(`- ${r}`);
271
+ L.push('');
272
+ L.push(`**${inv.counts.pages} pages** across ${inv.counts.surfaces} surfaces · ${inv.counts.with_states} with a states file · ${inv.counts.states} states in all.`);
273
+ L.push('');
274
+ L.push('## Surfaces');
275
+ L.push('');
276
+ L.push('| surface | module | lives at | answers at | pages |');
277
+ L.push('|---|---|---|---|---|');
278
+ for (const s of inv.surfaces) L.push(`| \`${s.id}\` | \`${s.module}\` | \`${s.at}\` | ${esc(s.answers_at)} | ${s.pages} |`);
279
+ L.push('');
280
+ for (const s of inv.surfaces) {
281
+ const rows = inv.pages.filter((p) => p.surface === s.id);
282
+ L.push(`## \`${s.id}\` — ${rows.length} pages`);
283
+ L.push('');
284
+ L.push('| id | title | url | files | states |');
285
+ L.push('|---|---|---|---|---|');
286
+ for (const p of rows) {
287
+ L.push(`| \`${p.id}\` | ${esc(p.title)} | \`${esc(p.url)}\` | ${p.files.length} | ${p.states.length ? esc(p.states.join(', ')) : '—'} |`);
288
+ }
289
+ L.push('');
290
+ }
291
+ L.push('---');
292
+ L.push('');
293
+ L.push('`{apex}` is the instance\'s own domain, and a title may still carry a `{{token}}` the server fills from the branding pack. The page files column counts the html plus the local stylesheets and scripts it loads; the JSON lists them.');
294
+ L.push('');
295
+ return L.join('\n');
296
+ }
297
+
298
+ // A missing file is stale by definition (sameContent would coerce null to '').
299
+ function matchesGenerated(have, want) {
300
+ if (have === null || have === undefined) return false;
301
+ return sameContent(have, want);
302
+ }
303
+
304
+ function write({ check = false, build = buildInventory } = {}) {
305
+ const inv = build();
306
+ const json = `${JSON.stringify(inv, null, 2)}\n`;
307
+ const md = renderReport(inv);
308
+ const targets = [[path.join(REPO_ROOT, INVENTORY_REL), json], [path.join(REPO_ROOT, REPORT_REL), md]];
309
+ if (check) {
310
+ const stale = [];
311
+ for (const [p, want] of targets) {
312
+ const have = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : null;
313
+ if (!matchesGenerated(have, want)) stale.push(toPosix(path.relative(REPO_ROOT, p)));
314
+ }
315
+ return { inv, stale };
316
+ }
317
+ for (const [p, content] of targets) fs.writeFileSync(p, content);
318
+ return { inv, written: [INVENTORY_REL, REPORT_REL] };
319
+ }
320
+
321
+ if (require.main === module) {
322
+ const args = process.argv.slice(2);
323
+ try {
324
+ if (args.includes('--json')) {
325
+ process.stdout.write(`${JSON.stringify(buildInventory(), null, 2)}\n`);
326
+ } else if (args.includes('--check')) {
327
+ const { stale } = write({ check: true });
328
+ if (stale.length) {
329
+ console.error(`page-inventory: STALE — ${stale.join(', ')} do not match the tree. Run: node scripts/gds/page-inventory.js`);
330
+ process.exitCode = 1;
331
+ } else {
332
+ console.log('page-inventory: artifacts match the tree.');
333
+ }
334
+ } else {
335
+ const { inv, written } = write();
336
+ console.log(`page-inventory: ${inv.counts.pages} pages across ${inv.counts.surfaces} surfaces -> wrote ${written.join(', ')}`);
337
+ }
338
+ } catch (e) {
339
+ console.error(e.message);
340
+ process.exitCode = 1;
341
+ }
342
+ }
343
+
344
+ module.exports = {
345
+ INVENTORY_REL, REPORT_REL, ID_RULE,
346
+ cmp, surfaceSlug, surfaceOrigin, pagePath, readPageMarkup, localAssets, statesFor,
347
+ enabledSurfaces, buildInventory, renderReport, matchesGenerated, write,
348
+ };
@@ -34,6 +34,7 @@ const STEPS = [
34
34
  { key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh + every module documented', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill, or a modules/ entry in docs/file-map.md for a new module)' },
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
+ { 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 },
37
38
  { 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 },
38
39
  { key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
39
40
  { 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.1054'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1055'; // 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,166 @@
1
+ // tests/page_inventory.mjs — the page inventory (BV2.TW02, task 1004314; the
2
+ // Tweak Mode chain, goal 1000095).
3
+ //
4
+ // What these tests defend:
5
+ // 1. COVERAGE. Every html page of every enabled web surface is listed — counted
6
+ // here from the tree independently of the generator, so a scanner that quietly
7
+ // finds less cannot pass.
8
+ // 2. THE ID RULE. <surface>:<page>, where the page half is body[data-page] when
9
+ // declared and the file name otherwise. TW03–TW06 store these ids.
10
+ // 3. DRIFT FAILS. --check reds on a stale artifact, on a duplicate id and on an
11
+ // empty surface — each by name.
12
+ // 4. PORTABILITY. Surfaces come from the loader over the ENABLED modules, so a
13
+ // disabled module's pages are absent with no edit to the generator.
14
+ // 5. DETERMINISM. The artifacts are committed; two runs give identical bytes.
15
+ //
16
+ // DB-free, network-free. Fixture surfaces are built in a fresh temp dir.
17
+ // Run: node --test tests/page_inventory.mjs
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import { spawnSync } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const inv = require(path.join(ROOT, 'scripts', 'gds', 'page-inventory.js'));
30
+ const { moduleWebSurfaces } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
31
+ const { isModuleEnabled } = require(path.join(ROOT, 'src', 'modules.js'));
32
+
33
+ function fixtureRoot() {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'page-inv-'));
35
+ }
36
+ function writeFiles(dir, files) {
37
+ for (const [name, body] of Object.entries(files)) {
38
+ const p = path.join(dir, name);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ fs.writeFileSync(p, body);
41
+ }
42
+ }
43
+ const page = ({ dataPage, dataTitle, title = 'T', head = '' } = {}) => `<!doctype html><html><head><title>${title}</title>${head}</head>`
44
+ + `<body${dataPage ? ` data-page="${dataPage}"` : ''}${dataTitle ? ` data-page-title="${dataTitle}"` : ''}><main>x</main></body></html>`;
45
+
46
+ test('COVERAGE: every html page of every enabled web surface is listed, and the three surfaces are there', () => {
47
+ const surfaces = moduleWebSurfaces({ isEnabled: isModuleEnabled });
48
+ const built = inv.buildInventory();
49
+ // Counted from the tree here, not taken from the generator.
50
+ let expected = 0;
51
+ for (const s of surfaces) expected += fs.readdirSync(s.dir).filter((n) => n.endsWith('.html')).length;
52
+ assert.ok(expected > 0, 'the enabled surfaces ship pages — a zero here is a scan defect in the test');
53
+ assert.equal(built.pages.length, expected, 'one inventory row per html page');
54
+ const ids = new Set(built.surfaces.map((s) => s.id));
55
+ for (const want of ['builders', 'landing', 'status']) assert.ok(ids.has(want), `surface ${want} listed (have: ${[...ids].join(', ')})`);
56
+ for (const p of built.pages) {
57
+ assert.match(p.id, /^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/, p.id);
58
+ assert.ok(p.files.length >= 1 && p.files[0].endsWith('.html'), `${p.id} names its html first`);
59
+ for (const f of p.files) assert.ok(fs.existsSync(path.join(ROOT, f)), `${p.id}: ${f} exists`);
60
+ assert.ok(p.url.startsWith('https://') && p.url.includes('{apex}'), `${p.id}: a portable url, no baked-in domain`);
61
+ }
62
+ });
63
+
64
+ test('THE ID RULE: a hall page is builders:<body[data-page]>, landing and status are <surface>:<file name>', () => {
65
+ const built = inv.buildInventory();
66
+ const byFile = new Map(built.pages.map((p) => [p.files[0], p]));
67
+ const hall = path.join(ROOT, 'modules', 'hall-ui', 'public');
68
+ for (const name of fs.readdirSync(hall).filter((n) => n.endsWith('.html'))) {
69
+ const dataPage = fs.readFileSync(path.join(hall, name), 'utf8').match(/<body\b[^>]*\bdata-page="([^"]+)"/)[1];
70
+ const row = byFile.get(`modules/hall-ui/public/${name}`);
71
+ assert.ok(row, `${name} is listed`);
72
+ assert.equal(row.id, `builders:${dataPage}`);
73
+ assert.equal(row.id_from, 'data-page');
74
+ }
75
+ assert.equal(byFile.get('modules/hall-ui/public/index.html').path, '/', 'the index answers the root');
76
+ assert.equal(byFile.get('modules/hall-ui/public/studio.html').id, 'builders:studio');
77
+ assert.equal(byFile.get('modules/public-landing/public/index.html').id, 'landing:index');
78
+ assert.equal(byFile.get('modules/status-ui/public/explore.html').id, 'status:explore');
79
+ assert.equal(byFile.get('modules/status-ui/public/explore.html').url, 'https://status.{apex}/explore');
80
+ // The rule is written into the artifact itself.
81
+ assert.ok(built.id_rule.some((r) => r.includes('<surface>:<page>')));
82
+ });
83
+
84
+ test('STATES: a page carries its own .states.json and its <page>-<variant> files, never another page\'s', () => {
85
+ const root = fixtureRoot();
86
+ const dir = path.join(root, 'site');
87
+ writeFiles(dir, {
88
+ 'a.html': page(), 'a-b.html': page(), 'c.html': page(),
89
+ 'a.states.json': JSON.stringify({ page: '/a', states: { one: {}, two: {} } }),
90
+ 'a-x.states.json': JSON.stringify({ page: '/a', states: { three: {} } }),
91
+ 'a-b.states.json': JSON.stringify({ page: '/a-b', states: { own: {} } }),
92
+ 'a-b-y.states.json': JSON.stringify({ page: '/a-b', states: { variant: {} } }),
93
+ });
94
+ const built = inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir }], root });
95
+ const get = (id) => built.pages.find((p) => p.id === id);
96
+ assert.deepEqual(get('fx:a').states, ['one', 'two', 'three']);
97
+ assert.deepEqual(get('fx:a').states_files, ['site/a.states.json', 'site/a-x.states.json']);
98
+ assert.deepEqual(get('fx:a-b').states, ['own', 'variant'], 'a-b-y belongs to a-b, the longest page prefix');
99
+ assert.deepEqual(get('fx:c').states, []);
100
+ assert.deepEqual(get('fx:c').states_files, []);
101
+ });
102
+
103
+ test('FILES: local stylesheets and scripts resolve (the hall\'s /builders/ prefix too); CDNs and data: URIs do not', () => {
104
+ const root = fixtureRoot();
105
+ const dir = path.join(root, 'site');
106
+ writeFiles(dir, {
107
+ 'p.html': page({ head: '<link rel="stylesheet" href="/builders/style.css?v=1"><link rel="icon" href="data:x"><link rel="stylesheet" href="https://fonts.example/css">'
108
+ + '<script src="https://cdn.example/lib.js"></script><script src="/app.js"></script><script src="/app.js?v=2"></script><script src="/gone.js"></script>' }),
109
+ 'style.css': '', 'app.js': '',
110
+ });
111
+ const built = inv.buildInventory({ surfaces: [{ key: 'fx', apexRoot: true, dir }], root });
112
+ assert.deepEqual(built.pages[0].files, ['site/p.html', 'site/style.css', 'site/app.js']);
113
+ assert.equal(built.pages[0].id, 'landing:p', 'the apex-root surface is "landing"');
114
+ assert.equal(built.pages[0].url, 'https://{apex}/p');
115
+ });
116
+
117
+ test('DRIFT FAILS: a duplicate id, a malformed id and an empty surface each fail by name', () => {
118
+ const root = fixtureRoot();
119
+ const dup = path.join(root, 'dup');
120
+ writeFiles(dup, { 'one.html': page({ dataPage: 'same' }), 'two.html': page({ dataPage: 'same' }) });
121
+ assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: dup }], root }),
122
+ (e) => /dup\/two\.html and dup\/one\.html both claim the page id "fx:same"/.test(e.message));
123
+ const bad = path.join(root, 'bad');
124
+ writeFiles(bad, { 'x.html': page({ dataPage: 'Not A Slug' }) });
125
+ assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: bad }], root }), /page "Not A Slug" is not \[a-z0-9-\]/);
126
+ const empty = path.join(root, 'empty');
127
+ fs.mkdirSync(empty);
128
+ assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'fx', host: 'fx.', dir: empty }], root }), /surface "fx" \(empty\) has no \.html pages/);
129
+ const one = path.join(root, 'one');
130
+ writeFiles(one, { 'i.html': page() });
131
+ assert.throws(() => inv.buildInventory({ surfaces: [{ key: 'a', host: 'fx.', dir: one }, { key: 'b', host: 'fx.', dir: one }], root }),
132
+ /surfaces "a" and "b" both derive the id prefix "fx"/);
133
+ });
134
+
135
+ test('DRIFT FAILS: --check reports both artifacts stale when the tree says otherwise, and the committed pair is fresh', () => {
136
+ assert.deepEqual(inv.write({ check: true }).stale, [], 'the committed docs/page-inventory.{json,md} match this tree');
137
+ const real = inv.buildInventory();
138
+ const drifted = () => ({ ...real, pages: real.pages.map((p) => (p.id === 'builders:studio' ? { ...p, title: 'Studio, renamed' } : p)) });
139
+ assert.deepEqual(inv.write({ check: true, build: drifted }).stale, [inv.INVENTORY_REL, inv.REPORT_REL]);
140
+ assert.equal(inv.matchesGenerated(null, ''), false, 'a missing artifact is stale, never vacuously fresh');
141
+ assert.equal(inv.matchesGenerated('a\r\nb\n', 'a\nb\n'), true, 'CRLF checkouts are not drift');
142
+ });
143
+
144
+ test('DRIFT FAILS: the CLI --check exits 0 on this tree (the gate fitness.js and ship-check.js run)', () => {
145
+ const r = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'gds', 'page-inventory.js'), '--check'], { cwd: ROOT, encoding: 'utf8' });
146
+ assert.equal(r.status, 0, r.stderr);
147
+ const fitness = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'fitness.js'), 'utf8');
148
+ const shipCheck = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-check.js'), 'utf8');
149
+ assert.match(fitness, /'page-inventory\.js'\), '--check'\]/, 'fitness.js runs the --check');
150
+ assert.match(shipCheck, /'scripts\/gds\/page-inventory\.js', '--check'\]/, 'ship-check.js runs the --check');
151
+ });
152
+
153
+ test('PORTABILITY: a disabled module\'s surface and pages are absent, with no edit to the generator', () => {
154
+ const surfaces = moduleWebSurfaces({ isEnabled: (k) => isModuleEnabled(k) && k !== 'public-landing' });
155
+ const built = inv.buildInventory({ surfaces });
156
+ assert.ok(!built.surfaces.some((s) => s.id === 'landing'), 'no landing surface');
157
+ assert.ok(!built.pages.some((p) => p.surface === 'landing'), 'no landing pages');
158
+ assert.ok(built.pages.some((p) => p.surface === 'builders'), 'the rest stay');
159
+ });
160
+
161
+ test('DETERMINISM: two builds give identical bytes', () => {
162
+ const a = JSON.stringify(inv.buildInventory());
163
+ const b = JSON.stringify(inv.buildInventory());
164
+ assert.equal(a, b);
165
+ assert.equal(inv.renderReport(JSON.parse(a)), inv.renderReport(JSON.parse(b)));
166
+ });