rcf-lite 0.28.4 → 0.29.0

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.
Files changed (168) hide show
  1. package/CHANGELOG.md +107 -1
  2. package/bin/rcf.js +4 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/define-draft-shapes.md +89 -0
  5. package/guidance/define-intake.md +97 -0
  6. package/guidance/define-intent.md +107 -0
  7. package/guidance/discover-prototype.md +67 -0
  8. package/guidance/discovery-prototypes.md +69 -0
  9. package/guidance/harness-template.md +34 -0
  10. package/guidance/managed/agent-instructions-block.hash +1 -1
  11. package/guidance/managed/agent-instructions-block.md +34 -0
  12. package/guidance/manifest.json +9 -2
  13. package/package.json +1 -1
  14. package/rcf/adrs/adr-4124-define-freeze-verb.json +30 -0
  15. package/rcf/adrs/adr-4126-persona-tagged-blockers-two-levels.json +40 -0
  16. package/rcf/adrs/adr-4127-readiness-tab-order-and-register.json +40 -0
  17. package/rcf/adrs/adr-4129-questions-harness-driven-loop.json +25 -0
  18. package/rcf/adrs/adr-4130-intake-harness-split-tool-scanned.json +20 -0
  19. package/rcf/adrs/adr-4132-prototypes-in-discovery-as-evidence.json +20 -0
  20. package/rcf/adrs/adr-4133-dag-dependency-depth-layout.json +40 -0
  21. package/rcf/adrs/adr-4134-index-json-route.json +35 -0
  22. package/rcf/adrs/adr-4135-readiness-po-register-and-adapter.json +35 -0
  23. package/rcf/adrs/adr-4136-embedding-host-fixture.json +35 -0
  24. package/rcf/code-nodes/cn-725.json +18 -2
  25. package/rcf/code-nodes/cn-726.json +10 -2
  26. package/rcf/code-nodes/cn-727.json +13 -4
  27. package/rcf/code-nodes/cn-728.json +18 -2
  28. package/rcf/code-nodes/cn-729.json +10 -2
  29. package/rcf/code-nodes/cn-730.json +13 -3
  30. package/rcf/code-nodes/cn-731.json +20 -0
  31. package/rcf/code-nodes/cn-732.json +14 -0
  32. package/rcf/code-nodes/cn-733.json +14 -0
  33. package/rcf/code-nodes/cn-734.json +14 -0
  34. package/rcf/code-nodes/cn-735.json +20 -0
  35. package/rcf/code-nodes/cn-736.json +14 -0
  36. package/rcf/code-nodes/cn-737.json +14 -0
  37. package/rcf/code-nodes/cn-738.json +14 -0
  38. package/rcf/code-nodes/cn-739.json +18 -0
  39. package/rcf/code-nodes/cn-740.json +19 -0
  40. package/rcf/code-nodes/cn-741.json +16 -0
  41. package/rcf/code-nodes/cn-742.json +16 -0
  42. package/rcf/code-nodes/cn-743.json +15 -0
  43. package/rcf/code-nodes/cn-744.json +22 -0
  44. package/rcf/code-nodes/cn-745.json +16 -0
  45. package/rcf/code-nodes/cn-746.json +14 -0
  46. package/rcf/code-nodes/cn-747.json +18 -0
  47. package/rcf/code-nodes/cn-748.json +16 -0
  48. package/rcf/code-nodes/cn-749.json +16 -0
  49. package/rcf/code-nodes/cn-750.json +15 -0
  50. package/rcf/code-nodes/cn-751.json +18 -0
  51. package/rcf/code-nodes/cn-752.json +14 -0
  52. package/rcf/fbs/fbs-196.json +13 -5
  53. package/rcf/fbs/fbs-198.json +42 -0
  54. package/rcf/fbs/fbs-199.json +59 -0
  55. package/rcf/fbs/fbs-200.json +46 -0
  56. package/rcf/fbs/fbs-201.json +49 -0
  57. package/rcf/fbs/fbs-202.json +110 -0
  58. package/rcf/fbs/fbs-203.json +100 -0
  59. package/rcf/requirements/req-002.json +2 -2
  60. package/rcf/requirements/req-173.json +2 -2
  61. package/rcf/requirements/req-174.json +10 -4
  62. package/rcf/requirements/req-175.json +10 -4
  63. package/rcf/requirements/req-176.json +25 -0
  64. package/rcf/requirements/req-180.json +31 -0
  65. package/rcf/requirements/req-181.json +31 -0
  66. package/rcf/requirements/req-182.json +25 -0
  67. package/rcf/requirements/req-186.json +25 -0
  68. package/rcf/requirements/req-187.json +31 -0
  69. package/rcf/requirements/req-188.json +25 -0
  70. package/rcf/tacs/tac-4121-define-ledgers.json +15 -3
  71. package/rcf/tacs/tac-4122-define-gates.json +40 -4
  72. package/rcf/tacs/tac-4123-define-readiness.json +39 -3
  73. package/rcf/tacs/tac-4124-define-freeze.json +28 -0
  74. package/rcf/tacs/tac-4127-readiness-view.json +49 -0
  75. package/rcf/tacs/tac-4128-define-guidance.json +53 -0
  76. package/rcf/tacs/tac-4129-define-questions.json +45 -0
  77. package/rcf/tacs/tac-4130-define-intake-brief.json +35 -0
  78. package/rcf/tacs/tac-4131-build-dag-view.json +39 -0
  79. package/rcf/tacs/tac-4132-id-lookup.json +42 -0
  80. package/rcf/tacs/tac-4133-readiness-po-layer.json +37 -0
  81. package/rcf/tacs/tac-4134-wespa-host-fixture.json +54 -0
  82. package/rcf/test-suites/ts-016.json +3 -3
  83. package/rcf/test-suites/ts-215.json +148 -4
  84. package/rcf/test-suites/ts-216.json +19 -3
  85. package/rcf/test-suites/ts-218.json +78 -0
  86. package/rcf/test-suites/ts-219.json +70 -0
  87. package/rcf/test-suites/ts-220.json +78 -0
  88. package/rcf/test-suites/ts-221.json +78 -0
  89. package/rcf/test-suites/ts-222.json +100 -0
  90. package/rcf/test-suites/ts-223.json +94 -0
  91. package/rcf/test-suites/ts-224.json +70 -0
  92. package/rcf/test-suites/ts-225.json +78 -0
  93. package/rcf/test-suites/ts-226.json +158 -0
  94. package/rcf/test-suites/ts-227.json +38 -0
  95. package/rcf/test-suites/ts-228.json +86 -0
  96. package/rcf/test-suites/ts-229.json +83 -0
  97. package/rcf/user-stories/us-17302.json +50 -0
  98. package/rcf/user-stories/us-17401.json +14 -2
  99. package/rcf/user-stories/us-17402.json +55 -0
  100. package/rcf/user-stories/us-17403.json +60 -0
  101. package/rcf/user-stories/us-17502.json +60 -0
  102. package/rcf/user-stories/us-17503.json +65 -0
  103. package/rcf/user-stories/us-17601.json +58 -0
  104. package/rcf/user-stories/us-18001.json +55 -0
  105. package/rcf/user-stories/us-18002.json +35 -0
  106. package/rcf/user-stories/us-18101.json +35 -0
  107. package/rcf/user-stories/us-18202.json +40 -0
  108. package/rcf/user-stories/us-18601.json +55 -0
  109. package/rcf/user-stories/us-18602.json +50 -0
  110. package/rcf/user-stories/us-18603.json +45 -0
  111. package/rcf/user-stories/us-18701.json +50 -0
  112. package/rcf/user-stories/us-18702.json +50 -0
  113. package/rcf/user-stories/us-18801.json +45 -0
  114. package/rcf/user-stories/us-204.json +59 -0
  115. package/rcf/user-stories/us-205.json +64 -0
  116. package/rcf/user-stories/us-206.json +54 -0
  117. package/releases/releases.yaml +11 -1
  118. package/src/cli/freeze.js +730 -0
  119. package/src/cli/help.js +12 -0
  120. package/src/cli/ledger.js +228 -12
  121. package/src/cli/questions.js +288 -0
  122. package/src/cli/readiness.js +267 -49
  123. package/src/cli/view.js +29 -0
  124. package/src/define/freeze-record.js +30 -15
  125. package/src/define/ledgers.js +210 -11
  126. package/src/intake/index.js +1 -1
  127. package/src/intake/orchestrator.js +117 -0
  128. package/src/mcp/tools.js +354 -0
  129. package/src/query/delta.js +6 -0
  130. package/src/query/gates.js +314 -39
  131. package/src/query/index.js +16 -2
  132. package/src/query/questions.js +438 -0
  133. package/src/query/readiness.js +248 -7
  134. package/src/server/index.js +24 -2
  135. package/src/server/routes.js +68 -1
  136. package/src/setup/identity-seed.js +17 -0
  137. package/src/view/build-dag.js +563 -0
  138. package/src/view/components/badge.js +41 -0
  139. package/src/view/components/diff.js +72 -0
  140. package/src/view/components/doc-row.js +62 -0
  141. package/src/view/components/empty-state.js +35 -0
  142. package/src/view/components/entity-selector.js +137 -0
  143. package/src/view/components/filter-bar.js +97 -0
  144. package/src/view/components/findings-list.js +49 -0
  145. package/src/view/components/index.js +12 -0
  146. package/src/view/components/pill.js +46 -0
  147. package/src/view/components/sub-tab-strip.js +43 -0
  148. package/src/view/components/toast.js +25 -0
  149. package/src/view/components-fixture.js +265 -0
  150. package/src/view/doc-renderers/architecture.js +304 -0
  151. package/src/view/doc-renderers/build.js +160 -0
  152. package/src/view/doc-renderers/index.js +2 -0
  153. package/src/view/doc-renderers/prd.js +35 -6
  154. package/src/view/html-page.js +700 -602
  155. package/src/view/index.js +96 -3
  156. package/src/view/live-client.js +39 -6
  157. package/src/view/needs-work.js +79 -0
  158. package/src/view/page-init.js +2193 -0
  159. package/src/view/readiness/phrasebook.js +115 -0
  160. package/src/view/readiness/po-layer.js +288 -0
  161. package/src/view/readiness/question-adapter.js +329 -0
  162. package/src/view/readiness/stage-legend.js +71 -0
  163. package/src/view/readiness.js +505 -0
  164. package/src/view/style.css +2017 -382
  165. package/src/view/test-host/embed-client.js +121 -0
  166. package/src/view/test-host/fixture.js +183 -0
  167. package/src/view/test-host/lints.js +220 -0
  168. package/src/view/view-index.js +323 -0
@@ -0,0 +1,121 @@
1
+ // Wespa host fixture client (viewer UI refresh PR 9; TAC-4134).
2
+ // Served at /test-host.js by the viewer's dev server when --test-host
3
+ // is on (RCF_VIEW_TEST_HOST=1 equivalent). Wires three fixture
4
+ // behaviours:
5
+ // - Theme toggle: posts { type: "rcf-view-theme", theme } to the
6
+ // iframe.contentWindow, targeting window.location.origin so a
7
+ // cross-origin receiver would refuse the message. Does not read
8
+ // event.origin itself; the viewer does, and the fixture READS the
9
+ // iframe's data-theme attribute as evidence.
10
+ // - Sidebar drag: a 6px handle on the sidebar's right edge sets
11
+ // --wespa-sidebar-width on the <main> grid; clamped to 64..400 px
12
+ // live.
13
+ // - Collapse/Expand: snaps the sidebar between 64 px (collapsed) and
14
+ // 200 px (expanded) and toggles the .collapsed class.
15
+ //
16
+ // Does NOT persist theme or sidebar width to localStorage (ADR-4136):
17
+ // a passing run has to flip the iframe through postMessage, which is
18
+ // the invariant the fixture pins.
19
+ //
20
+ // Classic (non-module) browser script so the fixture page carries no
21
+ // inline <script> and the embed-client is a plain <script src>.
22
+
23
+ (function () {
24
+ var SIDEBAR_MIN = 64;
25
+ var SIDEBAR_EXPANDED = 200;
26
+ var SIDEBAR_MAX = 400;
27
+
28
+ function iframe() {
29
+ return document.getElementById('rcf-test-host-iframe');
30
+ }
31
+
32
+ function postThemeToIframe(theme) {
33
+ var el = iframe();
34
+ if (!el || !el.contentWindow) return;
35
+ try {
36
+ el.contentWindow.postMessage(
37
+ { type: 'rcf-view-theme', theme: theme },
38
+ window.location.origin,
39
+ );
40
+ } catch (e) { /* swallow */ }
41
+ }
42
+
43
+ function wireThemeToggle() {
44
+ var buttons = Array.prototype.slice.call(
45
+ document.querySelectorAll('[data-rcf-host-theme]'),
46
+ );
47
+ if (buttons.length === 0) return;
48
+ buttons.forEach(function (btn) {
49
+ btn.addEventListener('click', function () {
50
+ var theme = btn.getAttribute('data-rcf-host-theme');
51
+ if (theme !== 'light' && theme !== 'dark') return;
52
+ buttons.forEach(function (b) {
53
+ b.setAttribute('aria-pressed', b === btn ? 'true' : 'false');
54
+ });
55
+ postThemeToIframe(theme);
56
+ });
57
+ });
58
+ }
59
+
60
+ function setSidebarWidth(px) {
61
+ var shell = document.querySelector('main.wespa-shell');
62
+ if (!shell) return;
63
+ var clamped = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, Math.round(px)));
64
+ shell.style.setProperty('--wespa-sidebar-width', clamped + 'px');
65
+ var aside = document.querySelector('[data-rcf-host-sidebar]');
66
+ if (aside) {
67
+ if (clamped <= SIDEBAR_MIN) aside.classList.add('collapsed');
68
+ else aside.classList.remove('collapsed');
69
+ }
70
+ }
71
+
72
+ function wireSidebarDrag() {
73
+ var handle = document.querySelector('[data-rcf-host-sidebar-drag]');
74
+ var aside = document.querySelector('[data-rcf-host-sidebar]');
75
+ if (!handle || !aside) return;
76
+ var dragging = false;
77
+ handle.addEventListener('mousedown', function (ev) {
78
+ dragging = true;
79
+ handle.classList.add('dragging');
80
+ ev.preventDefault();
81
+ });
82
+ document.addEventListener('mousemove', function (ev) {
83
+ if (!dragging) return;
84
+ var rect = aside.getBoundingClientRect();
85
+ setSidebarWidth(ev.clientX - rect.left);
86
+ });
87
+ document.addEventListener('mouseup', function () {
88
+ if (!dragging) return;
89
+ dragging = false;
90
+ handle.classList.remove('dragging');
91
+ });
92
+ }
93
+
94
+ function wireSidebarToggle() {
95
+ var btn = document.querySelector('[data-rcf-host-sidebar-toggle]');
96
+ var aside = document.querySelector('[data-rcf-host-sidebar]');
97
+ if (!btn || !aside) return;
98
+ btn.addEventListener('click', function () {
99
+ if (aside.classList.contains('collapsed')) {
100
+ setSidebarWidth(SIDEBAR_EXPANDED);
101
+ btn.textContent = 'Collapse';
102
+ } else {
103
+ setSidebarWidth(SIDEBAR_MIN);
104
+ btn.textContent = 'Expand';
105
+ }
106
+ });
107
+ }
108
+
109
+ function boot() {
110
+ setSidebarWidth(SIDEBAR_EXPANDED);
111
+ wireThemeToggle();
112
+ wireSidebarDrag();
113
+ wireSidebarToggle();
114
+ }
115
+
116
+ if (document.readyState === 'loading') {
117
+ document.addEventListener('DOMContentLoaded', boot);
118
+ } else {
119
+ boot();
120
+ }
121
+ })();
@@ -0,0 +1,183 @@
1
+ // Wespa host fixture (viewer UI refresh PR 9; TAC-4134, ADR-4136).
2
+ // Served by the viewer's own dev server at /test-host.html when
3
+ // --test-host is passed (or RCF_VIEW_TEST_HOST=1 is set); 404 otherwise
4
+ // so wespa's reverse-proxy allow-list is unaffected. Mimics the wespa
5
+ // shell: a 56px header carrying a Light/Dark toggle, a resizable
6
+ // sidebar (64px collapsed, 200-400px expanded, user-resizable live via
7
+ // a drag handle), a content pane carrying a single iframe pointed at
8
+ // the viewer root ("./?embed=1&theme=light" resolves to "/?..."
9
+ // because the fixture lives at the root path, not under /test-host/).
10
+ // The fixture carries no inline script; its client-side wiring lives
11
+ // at /test-host.js (served as a snapshotted asset at startup; see
12
+ // server/index.js).
13
+ //
14
+ // The fixture does not persist theme to localStorage: a passing run
15
+ // has to flip the iframe through the postMessage path, which is the
16
+ // invariant ADR-4136 pins. The host never reads event.origin itself
17
+ // (the viewer does); the host only READS the iframe's data-theme and
18
+ // data-embed attributes as evidence.
19
+
20
+ /**
21
+ * Return the fixture HTML string. Pure function, no I/O.
22
+ *
23
+ * @returns {string}
24
+ */
25
+ export function renderTestHostPage() {
26
+ return `<!DOCTYPE html>
27
+ <html lang="en-GB" data-rcf-test-host="wespa">
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <meta name="viewport" content="width=device-width, initial-scale=1">
31
+ <title>Wespa host fixture - rcf-lite viewer embed proof</title>
32
+ <style>
33
+ html, body { height: 100%; margin: 0; }
34
+ body {
35
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
36
+ display: grid;
37
+ grid-template-rows: 56px 1fr;
38
+ background: #0e1116;
39
+ color: #c9d1d9;
40
+ }
41
+ header.wespa-header {
42
+ height: 56px;
43
+ display: flex;
44
+ align-items: center;
45
+ gap: 1rem;
46
+ padding: 0 1rem;
47
+ background: #161b22;
48
+ border-bottom: 1px solid #30363d;
49
+ box-sizing: border-box;
50
+ }
51
+ header.wespa-header .wespa-brand {
52
+ font-weight: 700;
53
+ font-size: 0.95rem;
54
+ letter-spacing: -0.01em;
55
+ }
56
+ header.wespa-header .wespa-brand-sub {
57
+ color: #8b949e;
58
+ font-size: 0.85rem;
59
+ margin-left: 0.5rem;
60
+ }
61
+ header.wespa-header .wespa-theme-toggle {
62
+ margin-left: auto;
63
+ display: inline-flex;
64
+ align-items: center;
65
+ gap: 0.3rem;
66
+ font-size: 0.85rem;
67
+ }
68
+ header.wespa-header .wespa-theme-toggle button {
69
+ appearance: none;
70
+ border: 1px solid #30363d;
71
+ background: #0e1116;
72
+ color: #c9d1d9;
73
+ padding: 0.25rem 0.65rem;
74
+ border-radius: 6px;
75
+ cursor: pointer;
76
+ font-family: inherit;
77
+ font-size: 0.85rem;
78
+ }
79
+ header.wespa-header .wespa-theme-toggle button[aria-pressed="true"] {
80
+ background: #1f6feb;
81
+ border-color: #1f6feb;
82
+ color: #ffffff;
83
+ font-weight: 600;
84
+ }
85
+ main.wespa-shell {
86
+ display: grid;
87
+ grid-template-columns: var(--wespa-sidebar-width, 200px) 1fr;
88
+ overflow: hidden;
89
+ }
90
+ aside.wespa-sidebar {
91
+ background: #161b22;
92
+ border-right: 1px solid #30363d;
93
+ position: relative;
94
+ overflow: hidden;
95
+ display: flex;
96
+ flex-direction: column;
97
+ }
98
+ aside.wespa-sidebar .wespa-sidebar-head {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ padding: 0.5rem 0.75rem;
103
+ border-bottom: 1px solid #30363d;
104
+ }
105
+ aside.wespa-sidebar .wespa-sidebar-head button {
106
+ appearance: none;
107
+ border: 1px solid #30363d;
108
+ background: #0e1116;
109
+ color: #c9d1d9;
110
+ padding: 0.2rem 0.5rem;
111
+ border-radius: 6px;
112
+ cursor: pointer;
113
+ font-family: inherit;
114
+ font-size: 0.75rem;
115
+ }
116
+ aside.wespa-sidebar .wespa-sidebar-items {
117
+ flex: 1;
118
+ overflow: auto;
119
+ padding: 0.5rem;
120
+ color: #8b949e;
121
+ font-size: 0.85rem;
122
+ }
123
+ aside.wespa-sidebar .wespa-sidebar-items p { margin: 0 0 0.5rem; }
124
+ aside.wespa-sidebar.collapsed .wespa-sidebar-items { display: none; }
125
+ aside.wespa-sidebar .wespa-sidebar-drag {
126
+ position: absolute;
127
+ top: 0;
128
+ right: 0;
129
+ bottom: 0;
130
+ width: 6px;
131
+ cursor: col-resize;
132
+ background: transparent;
133
+ }
134
+ aside.wespa-sidebar .wespa-sidebar-drag:hover,
135
+ aside.wespa-sidebar .wespa-sidebar-drag.dragging {
136
+ background: #1f6feb;
137
+ }
138
+ section.wespa-content {
139
+ position: relative;
140
+ overflow: hidden;
141
+ background: #0e1116;
142
+ }
143
+ iframe.wespa-iframe {
144
+ border: 0;
145
+ width: 100%;
146
+ height: 100%;
147
+ display: block;
148
+ background: #ffffff;
149
+ }
150
+ </style>
151
+ </head>
152
+ <body>
153
+ <header class="wespa-header">
154
+ <span class="wespa-brand">Wespa</span>
155
+ <span class="wespa-brand-sub">rcf-lite viewer embed proof</span>
156
+ <div class="wespa-theme-toggle" role="group" aria-label="Iframe theme">
157
+ <span>Theme</span>
158
+ <button type="button" data-rcf-host-theme="light" aria-pressed="true">Light</button>
159
+ <button type="button" data-rcf-host-theme="dark" aria-pressed="false">Dark</button>
160
+ </div>
161
+ </header>
162
+ <main class="wespa-shell">
163
+ <aside class="wespa-sidebar" data-rcf-host-sidebar>
164
+ <div class="wespa-sidebar-head">
165
+ <span>Projects</span>
166
+ <button type="button" data-rcf-host-sidebar-toggle>Collapse</button>
167
+ </div>
168
+ <div class="wespa-sidebar-items">
169
+ <p>rcf-lite</p>
170
+ <p>wespa</p>
171
+ <p>design docs</p>
172
+ </div>
173
+ <div class="wespa-sidebar-drag" data-rcf-host-sidebar-drag title="Drag to resize"></div>
174
+ </aside>
175
+ <section class="wespa-content">
176
+ <iframe class="wespa-iframe" id="rcf-test-host-iframe" title="rcf-lite viewer" src="./?embed=1&amp;theme=light"></iframe>
177
+ </section>
178
+ </main>
179
+ <script src="./test-host.js" defer></script>
180
+ </body>
181
+ </html>
182
+ `;
183
+ }
@@ -0,0 +1,220 @@
1
+ // Three embedding lints (viewer UI refresh PR 9; TAC-4134, ADR-4136).
2
+ // Pure assertions each caller composes into whatever test harness it
3
+ // owns; test/view/wespa-host-fixture.test.js is the suite caller.
4
+ //
5
+ // (1) assertFramingHeaders(response)
6
+ // Refuses on X-Frame-Options, on CSP frame-ancestors that forbids
7
+ // same-origin, or on any header the viewer sets that would block
8
+ // wespa's same-origin iframe mount. The viewer today sets neither,
9
+ // so a passing run proves the browser accepts the iframe.
10
+ //
11
+ // (2) assertNoTargetTop(renderedHtml)
12
+ // Greps the server-rendered HTML for target="_top" and target='_top'
13
+ // and refuses on any match. Dex's contract (section 9) bans
14
+ // target="_top" links so a legitimate in-iframe navigation never
15
+ // breaks the embedding host out of its box.
16
+ //
17
+ // (3) assertLocalStorageNamespace(sourceTreeRoot)
18
+ // Walks every .js file under sourceTreeRoot/src/view (excluding
19
+ // vendored and .min.js) and asserts every localStorage.* and shared
20
+ // storage.* call site uses a key literal starting with
21
+ // "rcf-view:v1:" (or references STORAGE_NS declared equal to that
22
+ // prefix). Dex's contract (section 9) requires the viewer namespace
23
+ // every localStorage key because the viewer shares localStorage
24
+ // with the wespa SPA.
25
+
26
+ import { readFile, readdir } from 'node:fs/promises';
27
+ import { join, extname } from 'node:path';
28
+
29
+ const STORAGE_NS = 'rcf-view:v1:';
30
+
31
+ /**
32
+ * @param {{ headers: Headers | Record<string, string> | Map<string, string> }} response
33
+ * @returns {{ ok: true, headers: { xFrameOptions: null, csp: string | null } } | { ok: false, reason: string }}
34
+ */
35
+ export function assertFramingHeaders(response) {
36
+ const headers = response && response.headers;
37
+ if (!headers) return { ok: false, reason: 'no headers on response' };
38
+ const get = pickHeaderGetter(headers);
39
+ const xFrameOptions = get('x-frame-options');
40
+ if (xFrameOptions !== null && xFrameOptions !== undefined && String(xFrameOptions).trim() !== '') {
41
+ return { ok: false, reason: `X-Frame-Options present: ${xFrameOptions}` };
42
+ }
43
+ const csp = get('content-security-policy');
44
+ if (csp) {
45
+ const directive = parseCspFrameAncestors(String(csp));
46
+ if (directive !== null && !directive.allowsSameOrigin) {
47
+ return { ok: false, reason: `CSP frame-ancestors forbids same-origin: ${directive.raw}` };
48
+ }
49
+ }
50
+ return { ok: true, headers: { xFrameOptions: null, csp: csp ?? null } };
51
+ }
52
+
53
+ /**
54
+ * @param {string} renderedHtml
55
+ * @returns {{ ok: true, count: 0 } | { ok: false, count: number, matches: string[] }}
56
+ */
57
+ export function assertNoTargetTop(renderedHtml) {
58
+ if (typeof renderedHtml !== 'string') {
59
+ return { ok: false, count: 1, matches: ['non-string input'] };
60
+ }
61
+ const matches = [];
62
+ const re = /target\s*=\s*(?:"_top"|'_top'|_top\b)/gi;
63
+ let m;
64
+ while ((m = re.exec(renderedHtml)) !== null) {
65
+ matches.push(m[0]);
66
+ }
67
+ if (matches.length === 0) return { ok: true, count: 0 };
68
+ return { ok: false, count: matches.length, matches };
69
+ }
70
+
71
+ /**
72
+ * @param {string} sourceTreeRoot - absolute path to the package root
73
+ * (e.g. packages/rcf-lite). The lint walks sourceTreeRoot/src/view.
74
+ * @returns {Promise<{ ok: true, scanned: number } | { ok: false, violations: Array<{ file: string, line: number, snippet: string, key: string }> }>}
75
+ */
76
+ export async function assertLocalStorageNamespace(sourceTreeRoot) {
77
+ const viewRoot = join(sourceTreeRoot, 'src', 'view');
78
+ const files = await listJsFiles(viewRoot);
79
+ const violations = [];
80
+ for (const file of files) {
81
+ const text = await readFile(file, 'utf8');
82
+ const re = /(?:localStorage|storage|sessionStorage)\s*\.\s*(?:setItem|getItem|removeItem)\s*\(\s*([^,)]+)/g;
83
+ let m;
84
+ while ((m = re.exec(text)) !== null) {
85
+ const argExpr = m[1].trim();
86
+ const key = resolveKeyLiteral(argExpr, text);
87
+ if (key === null) continue;
88
+ if (!key.startsWith(STORAGE_NS)) {
89
+ const line = text.slice(0, m.index).split('\n').length;
90
+ violations.push({ file, line, snippet: m[0], key });
91
+ }
92
+ }
93
+ }
94
+ if (violations.length > 0) return { ok: false, violations };
95
+ return { ok: true, scanned: files.length };
96
+ }
97
+
98
+ /**
99
+ * Pick a case-insensitive header getter that works whether headers is
100
+ * a Headers object, a Map, or a plain record.
101
+ *
102
+ * @param {Headers | Map<string, string> | Record<string, string>} headers
103
+ * @returns {(name: string) => string | null | undefined}
104
+ */
105
+ function pickHeaderGetter(headers) {
106
+ if (typeof headers.get === 'function') return (name) => headers.get(name);
107
+ const map = /** @type {Record<string, string>} */ (headers);
108
+ return (name) => {
109
+ for (const k of Object.keys(map)) {
110
+ if (k.toLowerCase() === name.toLowerCase()) return map[k];
111
+ }
112
+ return null;
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Parse a CSP header string for a frame-ancestors directive. If the
118
+ * directive is present, return `{ allowsSameOrigin, raw }` where
119
+ * allowsSameOrigin is true if the directive contains 'self',
120
+ * window.location.origin, or '*'. Null if the directive is absent (no
121
+ * opinion on framing).
122
+ *
123
+ * @param {string} csp
124
+ * @returns {{ allowsSameOrigin: boolean, raw: string } | null}
125
+ */
126
+ function parseCspFrameAncestors(csp) {
127
+ const parts = csp.split(';');
128
+ for (const part of parts) {
129
+ const trimmed = part.trim();
130
+ if (!/^frame-ancestors\b/i.test(trimmed)) continue;
131
+ const value = trimmed.slice('frame-ancestors'.length).trim();
132
+ if (value.length === 0) return { allowsSameOrigin: false, raw: trimmed };
133
+ const tokens = value.split(/\s+/);
134
+ const allowsSameOrigin = tokens.some((t) => (
135
+ t === "'self'" || t === '*' || /^https?:\/\//i.test(t)
136
+ ));
137
+ return { allowsSameOrigin, raw: trimmed };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Resolve a storage key argument expression to its literal string if
144
+ * possible. Returns null when the key is dynamic (we only lint literals,
145
+ * since a dynamic key already has to be composed from a literal prefix
146
+ * further up the file to pin the namespace).
147
+ *
148
+ * Accepts:
149
+ * - "rcf-view:v1:openDetails"
150
+ * - 'rcf-view:v1:openDetails'
151
+ * - STORAGE_OPEN -> resolves if `var STORAGE_OPEN = STORAGE_NS + 'openDetails';` or similar
152
+ * - STORAGE_NS + 'something'
153
+ *
154
+ * @param {string} argExpr - the trimmed argument expression as source
155
+ * @param {string} fileText - the full file text (for const resolution)
156
+ * @returns {string | null}
157
+ */
158
+ function resolveKeyLiteral(argExpr, fileText) {
159
+ const strLit = argExpr.match(/^(['"`])((?:\\.|(?!\1).)*)\1/);
160
+ if (strLit) return strLit[2];
161
+ const addLit = argExpr.match(/^([A-Z_][A-Z0-9_]*)\s*\+\s*(['"`])((?:\\.|(?!\2).)*)\2/);
162
+ if (addLit) {
163
+ const base = resolveIdentifier(addLit[1], fileText);
164
+ if (base === null) return null;
165
+ return base + addLit[3];
166
+ }
167
+ const ident = argExpr.match(/^([A-Z_][A-Z0-9_]*)$/);
168
+ if (ident) {
169
+ return resolveIdentifier(ident[1], fileText);
170
+ }
171
+ return null;
172
+ }
173
+
174
+ /**
175
+ * Resolve a module-level `var X = '...';` or `var X = Y + '...';`
176
+ * declaration to its literal string.
177
+ *
178
+ * @param {string} name
179
+ * @param {string} fileText
180
+ * @returns {string | null}
181
+ */
182
+ function resolveIdentifier(name, fileText) {
183
+ const re = new RegExp(`(?:var|let|const)\\s+${name}\\s*=\\s*([^;\\n]+)[;\\n]`);
184
+ const m = fileText.match(re);
185
+ if (!m) return null;
186
+ return resolveKeyLiteral(m[1].trim(), fileText);
187
+ }
188
+
189
+ /**
190
+ * Walk a directory recursively and return every `.js` file path, excluding
191
+ * `vendored/` subtrees and `.min.js` artefacts.
192
+ *
193
+ * @param {string} dir
194
+ * @returns {Promise<string[]>}
195
+ */
196
+ async function listJsFiles(dir) {
197
+ const out = [];
198
+ async function walk(d) {
199
+ let entries;
200
+ try {
201
+ entries = await readdir(d, { withFileTypes: true });
202
+ } catch (err) {
203
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return;
204
+ throw err;
205
+ }
206
+ for (const e of entries) {
207
+ const full = join(d, e.name);
208
+ if (e.isDirectory()) {
209
+ if (e.name === 'vendored') continue;
210
+ await walk(full);
211
+ } else if (e.isFile()) {
212
+ if (extname(e.name) !== '.js') continue;
213
+ if (e.name.endsWith('.min.js')) continue;
214
+ out.push(full);
215
+ }
216
+ }
217
+ }
218
+ await walk(dir);
219
+ return out;
220
+ }