@rune-kit/rune 2.18.1 → 2.21.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 (46) hide show
  1. package/README.md +31 -12
  2. package/compiler/__tests__/comprehension.test.js +916 -0
  3. package/compiler/__tests__/governance-collector.test.js +376 -0
  4. package/compiler/__tests__/setup.test.js +5 -0
  5. package/compiler/analytics.js +5 -0
  6. package/compiler/bin/rune.js +165 -1
  7. package/compiler/comprehension-client.js +2348 -0
  8. package/compiler/comprehension.js +1254 -0
  9. package/compiler/governance-collector.js +382 -0
  10. package/compiler/schemas/comprehension.schema.json +87 -0
  11. package/compiler/schemas/governance.schema.json +78 -0
  12. package/compiler/transforms/branding.js +1 -1
  13. package/hooks/context-watch/index.cjs +24 -13
  14. package/hooks/hooks.json +2 -2
  15. package/hooks/lib/context-key.cjs +37 -0
  16. package/hooks/metrics-collector/index.cjs +37 -8
  17. package/hooks/pre-tool-guard/index.cjs +44 -0
  18. package/hooks/session-start/index.cjs +4 -10
  19. package/package.json +1 -1
  20. package/skills/adversary/SKILL.md +36 -3
  21. package/skills/adversary/references/cross-model-escalation.md +85 -0
  22. package/skills/adversary/references/reasoning-modes.md +95 -0
  23. package/skills/autopsy/SKILL.md +41 -0
  24. package/skills/ba/SKILL.md +106 -27
  25. package/skills/ba/references/ears-format.md +91 -0
  26. package/skills/brainstorm/SKILL.md +32 -7
  27. package/skills/completion-gate/SKILL.md +21 -10
  28. package/skills/converge/SKILL.md +178 -0
  29. package/skills/converge/references/eval-fixtures.md +59 -0
  30. package/skills/cook/SKILL.md +41 -6
  31. package/skills/debug/SKILL.md +4 -2
  32. package/skills/deploy/SKILL.md +23 -2
  33. package/skills/deploy/references/observability.md +146 -0
  34. package/skills/design/SKILL.md +15 -1
  35. package/skills/graft/SKILL.md +24 -8
  36. package/skills/onboard/SKILL.md +45 -0
  37. package/skills/perf/SKILL.md +4 -1
  38. package/skills/plan/SKILL.md +70 -7
  39. package/skills/plan/references/boundary-artifacts.md +127 -0
  40. package/skills/plan/references/plan-templates.md +28 -9
  41. package/skills/plan/references/vertical-slice.md +8 -6
  42. package/skills/preflight/SKILL.md +9 -3
  43. package/skills/review/SKILL.md +4 -2
  44. package/skills/skill-router/SKILL.md +2 -0
  45. package/skills/test/SKILL.md +15 -8
  46. package/skills/verification/SKILL.md +39 -7
@@ -0,0 +1,916 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { computeVerdictScore, generateComprehensionHTML } from '../comprehension.js';
4
+
5
+ // ─── (a) Empty data — no throw, contains verdict element + empty-state hint ───
6
+
7
+ describe('generateComprehensionHTML — empty data', () => {
8
+ it('does not throw on empty object', () => {
9
+ assert.doesNotThrow(() => generateComprehensionHTML({}));
10
+ });
11
+
12
+ it('returns a string', () => {
13
+ const result = generateComprehensionHTML({});
14
+ assert.equal(typeof result, 'string');
15
+ });
16
+
17
+ it('contains the verdict hero element', () => {
18
+ const result = generateComprehensionHTML({});
19
+ assert.ok(
20
+ result.includes('verdict-line') || result.includes('panel-verdict'),
21
+ 'Expected verdict panel marker in output',
22
+ );
23
+ });
24
+
25
+ it('contains an empty-state hint when there is no data', () => {
26
+ const result = generateComprehensionHTML({});
27
+ // Score shows — for null basis
28
+ assert.ok(
29
+ result.includes('—') || result.includes('no session data'),
30
+ 'Expected empty-state indicator (mdash or "no session data") in output',
31
+ );
32
+ });
33
+ });
34
+
35
+ // ─── (b) No http:// or https:// — truly self-contained ───
36
+
37
+ describe('generateComprehensionHTML — no external URLs', () => {
38
+ it('contains no http:// URLs (hard guard)', () => {
39
+ const result = generateComprehensionHTML({});
40
+ assert.ok(!result.includes('http://'), 'Found http:// in output — external URL!');
41
+ });
42
+
43
+ it('contains no https:// URLs (hard guard)', () => {
44
+ const result = generateComprehensionHTML({});
45
+ assert.ok(!result.includes('https://'), 'Found https:// in output — external URL!');
46
+ });
47
+
48
+ it('no external URLs even with full sample data', () => {
49
+ const result = generateComprehensionHTML({
50
+ project: 'TestProject',
51
+ modules: [{ id: 'm1', name: 'Auth', layer: 'service' }],
52
+ edges: [],
53
+ gates: [{ name: 'sentinel', fired: 3 }],
54
+ compliance: [{ pack: '@rune-business/finance', obligation: 'MUST log all decisions', status: 'met' }],
55
+ overview: {
56
+ total_sessions: 5,
57
+ avg_duration_min: 12,
58
+ total_tool_calls: 100,
59
+ total_skill_invocations: 30,
60
+ active_days: 3,
61
+ },
62
+ skillFrequency: [
63
+ { skill: 'cook', count: 10 },
64
+ { skill: 'scout', count: 5 },
65
+ ],
66
+ });
67
+ assert.ok(!result.includes('http://'), 'Found http:// with sample data');
68
+ assert.ok(!result.includes('https://'), 'Found https:// with sample data');
69
+ });
70
+ });
71
+
72
+ // ─── (c) XSS safety — </script> in project name must not break out ───
73
+
74
+ describe('generateComprehensionHTML — XSS / script injection guard', () => {
75
+ it('escapes </script> in project name so it cannot break out of the script tag', () => {
76
+ const malicious = 'evil</script><script>alert(1)</script>';
77
+ const result = generateComprehensionHTML({ project: malicious });
78
+ // The raw string </script> must NOT appear raw inside the JSON data literal.
79
+ // Strategy: extract ONLY the const D = ... ; line (ends at the first newline after the
80
+ // safeJson-produced string) and check it contains no raw </script>.
81
+ const dataMarker = 'const D =';
82
+ const dataStart = result.indexOf(dataMarker);
83
+ assert.ok(dataStart !== -1, 'Could not find "const D =" in output');
84
+ // The data line ends at the semicolon on the same logical line
85
+ // safeJson produces a single-line JSON string, so grab up to 'D;\n'
86
+ const lineEnd = result.indexOf(';\n', dataStart);
87
+ const dataLine = lineEnd !== -1 ? result.slice(dataStart, lineEnd + 2) : result.slice(dataStart, dataStart + 5000);
88
+ // The raw </script> must not appear inside the JSON data line
89
+ assert.ok(!dataLine.includes('</script>'), 'Raw </script> found inside JSON data line — XSS escape failed!');
90
+ });
91
+
92
+ it('\\u003c escape keeps JSON parseable in JS', () => {
93
+ const malicious = 'evil</script>';
94
+ const result = generateComprehensionHTML({ project: malicious });
95
+ // The JSON data block should use \\u003c instead of <
96
+ const scriptStart = result.indexOf('const D =');
97
+ assert.ok(scriptStart !== -1, 'Could not find "const D =" in output');
98
+ const dataLine = result.slice(scriptStart, scriptStart + 400);
99
+ assert.ok(dataLine.includes('\\u003c'), 'Expected \\u003c escape in JSON data block');
100
+ });
101
+ });
102
+
103
+ // ─── (d) Sample data — HTML includes score and gate name ───
104
+
105
+ describe('generateComprehensionHTML — sample data renders correctly', () => {
106
+ const sampleData = {
107
+ project: 'MyProject',
108
+ health_score: 72,
109
+ generated_at: '2026-06-20T12:00:00.000Z',
110
+ modules: [
111
+ { id: 'auth', name: 'Auth', layer: 'service', type: 'module' },
112
+ { id: 'db', name: 'Database', layer: 'data', type: 'module' },
113
+ ],
114
+ edges: [{ from: 'auth', to: 'db', category: 'dependency' }],
115
+ gates: [
116
+ { name: 'sentinel', fired: 5, passed: 0, bypassed: 0, blocked: 0, ts: '2026-06-20T10:00:00.000Z' },
117
+ { name: 'preflight', fired: 3, passed: 0, bypassed: 0, blocked: 0, ts: null },
118
+ ],
119
+ compliance: [
120
+ { pack: '@rune-business/finance', obligation: 'MUST log all decisions', status: 'met' },
121
+ { pack: '@rune-business/legal', obligation: 'MUST review contracts', status: 'unknown' },
122
+ ],
123
+ overview: {
124
+ total_sessions: 12,
125
+ avg_duration_min: 18,
126
+ total_tool_calls: 240,
127
+ total_skill_invocations: 60,
128
+ active_days: 8,
129
+ },
130
+ skillFrequency: [
131
+ { skill: 'cook', count: 20 },
132
+ { skill: 'plan', count: 10 },
133
+ ],
134
+ };
135
+
136
+ it('includes the health score (72) in the output', () => {
137
+ const result = generateComprehensionHTML(sampleData);
138
+ // Score appears as data-target or in count-up JS or as literal text
139
+ assert.ok(result.includes('72') || result.includes('"verdictScore":72'), 'Expected health score 72 in output');
140
+ });
141
+
142
+ it('includes a gate name from the data', () => {
143
+ const result = generateComprehensionHTML(sampleData);
144
+ assert.ok(result.includes('sentinel') || result.includes('"sentinel"'), 'Expected gate name "sentinel" in output');
145
+ });
146
+
147
+ it('includes project name', () => {
148
+ const result = generateComprehensionHTML(sampleData);
149
+ assert.ok(result.includes('MyProject'), 'Expected project name in output');
150
+ });
151
+
152
+ it('includes all 5 tab panels', () => {
153
+ const result = generateComprehensionHTML(sampleData);
154
+ for (const id of ['panel-verdict', 'panel-govern', 'panel-measure', 'panel-understand', 'panel-improve']) {
155
+ assert.ok(result.includes(id), `Missing tab panel: ${id}`);
156
+ }
157
+ });
158
+
159
+ it('does not produce NaN in the output', () => {
160
+ const result = generateComprehensionHTML(sampleData);
161
+ assert.ok(!result.includes('>NaN<'), 'NaN found in rendered HTML');
162
+ assert.ok(!result.includes('"NaN"'), 'NaN string found in data JSON');
163
+ });
164
+ });
165
+
166
+ // ─── computeVerdictScore unit tests ───
167
+
168
+ describe('computeVerdictScore', () => {
169
+ it('returns null score when all dimensions are empty', () => {
170
+ const { score } = computeVerdictScore({});
171
+ assert.equal(score, null);
172
+ });
173
+
174
+ it('returns a score when modules are present', () => {
175
+ const { score } = computeVerdictScore({
176
+ modules: [{ id: 'm1', name: 'Auth' }],
177
+ });
178
+ assert.ok(typeof score === 'number' && score >= 0 && score <= 100, 'Score out of range');
179
+ });
180
+
181
+ it('score is capped at 100', () => {
182
+ const { score } = computeVerdictScore({
183
+ modules: Array.from({ length: 100 }, (_, i) => ({ id: `m${i}`, name: `M${i}` })),
184
+ gates: Array.from({ length: 8 }, (_, i) => ({ name: `g${i}`, fired: 5 })),
185
+ compliance: Array.from({ length: 50 }, () => ({ status: 'met' })),
186
+ overview: { total_sessions: 100 },
187
+ });
188
+ assert.ok(score <= 100, 'Score exceeded 100');
189
+ });
190
+
191
+ it('includes "governance" basis when gates are fired', () => {
192
+ const { basis } = computeVerdictScore({
193
+ gates: [{ name: 'sentinel', fired: 3 }],
194
+ });
195
+ assert.ok(basis.includes('governance'), 'Expected "governance" in basis');
196
+ });
197
+
198
+ it('does NOT include "governance" basis when gates are defined but never fired (Fix #4)', () => {
199
+ // gates.length > 0 but all fired = 0 — must NOT flip verdict from — to a number
200
+ const { score, basis } = computeVerdictScore({
201
+ gates: [
202
+ { name: 'sentinel', fired: 0 },
203
+ { name: 'preflight', fired: 0 },
204
+ ],
205
+ });
206
+ assert.ok(!basis.includes('governance'), 'governance must not appear in basis when no gates fired');
207
+ assert.equal(score, null, 'Score must be null (no real basis) when gates defined but never fired');
208
+ });
209
+ });
210
+
211
+ // ─── (e) XSS — HTML injection in title and h1 (Fix #1 BLOCK) ───
212
+
213
+ describe('generateComprehensionHTML — HTML injection in title/h1', () => {
214
+ it('escapes <img> injection in <title> — raw tag must not appear outside JSON blob', () => {
215
+ const malicious = '<img src=x onerror=alert(1)>';
216
+ const result = generateComprehensionHTML({ project: malicious });
217
+ // Extract everything except the const D = ... JSON blob
218
+ const dataStart = result.indexOf('const D =');
219
+ const dataEnd = result.indexOf(';\n', dataStart);
220
+ const beforeData = dataStart !== -1 ? result.slice(0, dataStart) : result;
221
+ const afterData = dataStart !== -1 && dataEnd !== -1 ? result.slice(dataEnd + 2) : '';
222
+ const outsideJson = beforeData + afterData;
223
+ assert.ok(!outsideJson.includes('<img'), 'Raw <img> tag found outside JSON blob — XSS in title or h1!');
224
+ });
225
+
226
+ it('escapes & < > " in project name in <title>', () => {
227
+ const result = generateComprehensionHTML({ project: '<Evil & "Corp">' });
228
+ // Extract <title> content
229
+ const titleStart = result.indexOf('<title>');
230
+ const titleEnd = result.indexOf('</title>');
231
+ assert.ok(titleStart !== -1 && titleEnd !== -1, 'No <title> tag found');
232
+ const titleContent = result.slice(titleStart, titleEnd);
233
+ assert.ok(!titleContent.includes('<Evil'), 'Raw < in <title>');
234
+ assert.ok(titleContent.includes('&lt;Evil'), 'Expected &lt; in <title>');
235
+ });
236
+ });
237
+
238
+ // ─── (f) U+2028 line separator escape in JSON blob (Fix #2 HIGH) ───
239
+
240
+ describe('generateComprehensionHTML — U+2028 / U+2029 line terminator escape', () => {
241
+ it('escapes U+2028 (LS) in data — no raw line separator in script block', () => {
242
+ // U+2028 in project name would terminate a JS string literal if unescaped
243
+ const withLS = `before${String.fromCharCode(0x2028)}after`; // U+2028 LINE SEPARATOR
244
+ const result = generateComprehensionHTML({ project: withLS });
245
+ const dataStart = result.indexOf('const D =');
246
+ assert.ok(dataStart !== -1, 'const D = not found');
247
+ const dataSlice = result.slice(dataStart, dataStart + 2000);
248
+ assert.ok(!dataSlice.includes(String.fromCharCode(0x2028)), 'Raw U+2028 found in script block — must be escaped');
249
+ assert.ok(dataSlice.includes('\\u2028'), 'Expected \\u2028 escape in JSON data block');
250
+ });
251
+
252
+ it('escapes U+2029 (PS) in data — no raw paragraph separator in script block', () => {
253
+ const withPS = `before${String.fromCharCode(0x2029)}after`; // U+2029 PARAGRAPH SEPARATOR
254
+ const result = generateComprehensionHTML({ project: withPS });
255
+ const dataStart = result.indexOf('const D =');
256
+ assert.ok(dataStart !== -1, 'const D = not found');
257
+ const dataSlice = result.slice(dataStart, dataStart + 2000);
258
+ assert.ok(!dataSlice.includes(String.fromCharCode(0x2029)), 'Raw U+2029 found in script block — must be escaped');
259
+ assert.ok(dataSlice.includes('\\u2029'), 'Expected \\u2029 escape in JSON data block');
260
+ });
261
+ });
262
+
263
+ // ─── (g) health_score clamp + NaN-guard (Fix #6 MEDIUM) ───
264
+
265
+ describe('generateComprehensionHTML — health_score clamp and NaN-guard', () => {
266
+ it('clamps health_score > 100 to 100 (not rendered as 150)', () => {
267
+ const result = generateComprehensionHTML({ health_score: 150 });
268
+ // Score in initial render must be ≤ 100
269
+ const scoreNumMatch = result.match(/<span id="score-num">(\d+)<\/span>/);
270
+ assert.ok(scoreNumMatch, 'score-num span not found');
271
+ const rendered = parseInt(scoreNumMatch[1], 10);
272
+ assert.ok(rendered <= 100, `Rendered score ${rendered} exceeds 100 — clamp failed`);
273
+ });
274
+
275
+ it('treats health_score: NaN as absent (renders — not NaN)', () => {
276
+ const result = generateComprehensionHTML({ health_score: Number.NaN });
277
+ // NaN score should fall through to computedScore (which will be null for empty data)
278
+ // so the — path renders instead of a NaN number
279
+ assert.ok(!result.includes('>NaN<'), 'NaN rendered in score element');
280
+ assert.ok(!result.includes('"verdictScore":null') || result.includes('&mdash;'), 'Expected — for null/NaN score');
281
+ });
282
+ });
283
+
284
+ // ─── (h) self-containment hardening (Fix — extend existing guard) ───
285
+
286
+ describe('generateComprehensionHTML — self-containment hardening', () => {
287
+ it('contains no <link> tags (no external stylesheet)', () => {
288
+ const result = generateComprehensionHTML({});
289
+ assert.ok(!result.includes('<link'), 'Found <link> tag — external resource!');
290
+ });
291
+
292
+ it('contains no @import (no external CSS imports)', () => {
293
+ const result = generateComprehensionHTML({});
294
+ assert.ok(!result.includes('@import'), 'Found @import — external CSS import!');
295
+ });
296
+
297
+ it('contains no url(http — no external resources in CSS', () => {
298
+ const result = generateComprehensionHTML({});
299
+ assert.ok(!result.includes('url(http'), 'Found url(http — external resource in CSS!');
300
+ });
301
+
302
+ it('contains no protocol-relative font/script references (//fonts. etc)', () => {
303
+ const result = generateComprehensionHTML({});
304
+ // Disallow //fonts. or //cdn. or //unpkg. etc — but allow bare // in comments
305
+ const protocolRelative = /src=["']\/\/|href=["']\/\//.test(result);
306
+ assert.ok(!protocolRelative, 'Found protocol-relative external reference in src/href!');
307
+ });
308
+ });
309
+
310
+ // ─── (i) skillFrequency sessions_count shape regression guard (Fix #5) ───
311
+
312
+ describe('generateComprehensionHTML — skillFrequency .count field contract', () => {
313
+ it('does not produce NaN bars when skillFrequency uses .count correctly', () => {
314
+ // Verifies the renderer reads .count (mapping from sessions_count happens in cmdDashboard)
315
+ const result = generateComprehensionHTML({
316
+ skillFrequency: [
317
+ { skill: 'cook', count: 15 },
318
+ { skill: 'scout', count: 7 },
319
+ ],
320
+ overview: { total_sessions: 5, total_skill_invocations: 20 },
321
+ });
322
+ assert.ok(!result.includes('>NaN<'), 'NaN found in rendered skill bars');
323
+ assert.ok(!result.includes('"NaN"'), 'NaN string in JSON blob');
324
+ });
325
+
326
+ it('renders without crash when skillFrequency entries have sessions_count (unmapped shape)', () => {
327
+ // If sessions_count leaks through without mapping, bars show 0 not NaN (safeInt guards)
328
+ // This test documents the expected degraded behavior (not crash) when mapping is missing
329
+ assert.doesNotThrow(() =>
330
+ generateComprehensionHTML({
331
+ skillFrequency: [{ skill: 'cook', sessions_count: 15 }],
332
+ overview: { total_sessions: 5, total_skill_invocations: 20 },
333
+ }),
334
+ );
335
+ });
336
+ });
337
+
338
+ // ─── Phase 4: Understand tab features ───
339
+
340
+ const phase4Data = {
341
+ project: 'Phase4Test',
342
+ generated_at: '2026-06-20T10:00:00.000Z',
343
+ modules: [
344
+ {
345
+ id: 'auth',
346
+ name: 'AuthService',
347
+ layer: 'service',
348
+ type: 'service',
349
+ complexity: 'complex',
350
+ summary: 'Handles authentication.',
351
+ },
352
+ { id: 'db', name: 'UserRepo', layer: 'data', type: 'class', complexity: 'simple', summary: 'Database access.' },
353
+ {
354
+ id: 'api',
355
+ name: 'AuthController',
356
+ layer: 'api',
357
+ type: 'endpoint',
358
+ complexity: 'moderate',
359
+ summary: 'REST endpoints.',
360
+ },
361
+ {
362
+ id: 'cfg',
363
+ name: 'app.config',
364
+ layer: 'infra',
365
+ type: 'config',
366
+ complexity: 'simple',
367
+ summary: 'App config file.',
368
+ },
369
+ ],
370
+ edges: [
371
+ { from: 'api', to: 'auth', category: 'structural' },
372
+ { from: 'auth', to: 'db', category: 'data-flow' },
373
+ { from: 'db', to: 'cfg', category: 'dependency' },
374
+ ],
375
+ domains: [
376
+ {
377
+ name: 'Authentication',
378
+ summary: 'User identity flows',
379
+ flows: [{ name: 'Login', steps: ['Submit credentials', 'Validate', 'Issue token'] }],
380
+ },
381
+ ],
382
+ };
383
+
384
+ describe('generateComprehensionHTML — Phase 4: filter controls for present node types', () => {
385
+ it('(a) output contains filter controls for present node types', () => {
386
+ const result = generateComprehensionHTML(phase4Data);
387
+ // The node types present: service, class, endpoint, config
388
+ // Each should appear as a filter checkbox label in the embedded JS or rendered HTML
389
+ assert.ok(result.includes('service') || result.includes('"service"'), 'Expected "service" type in output');
390
+ assert.ok(result.includes('class') || result.includes('"class"'), 'Expected "class" type in output');
391
+ assert.ok(result.includes('endpoint') || result.includes('"endpoint"'), 'Expected "endpoint" type in output');
392
+ });
393
+
394
+ it('(a) output contains filter controls for present edge categories', () => {
395
+ const result = generateComprehensionHTML(phase4Data);
396
+ // Edge categories present: structural, data-flow, dependency
397
+ assert.ok(
398
+ result.includes('structural') || result.includes('"structural"'),
399
+ 'Expected "structural" category in output',
400
+ );
401
+ assert.ok(
402
+ result.includes('data-flow') || result.includes('"data-flow"'),
403
+ 'Expected "data-flow" category in output',
404
+ );
405
+ });
406
+ });
407
+
408
+ describe('generateComprehensionHTML — Phase 4: domain view markup', () => {
409
+ it('(b) domain view markup present when domains exist', () => {
410
+ const result = generateComprehensionHTML(phase4Data);
411
+ // Domain view button and domain name should appear in the JS string
412
+ assert.ok(
413
+ result.includes('Domain View') || result.includes('u-domain'),
414
+ 'Expected Domain View markup or toggle in output',
415
+ );
416
+ // Domain name embedded in data
417
+ assert.ok(
418
+ result.includes('Authentication') || result.includes('"Authentication"'),
419
+ 'Expected domain name "Authentication" in output',
420
+ );
421
+ });
422
+
423
+ it('(b) domain steps embedded in output', () => {
424
+ const result = generateComprehensionHTML(phase4Data);
425
+ assert.ok(
426
+ result.includes('Submit credentials') || result.includes('"Submit credentials"'),
427
+ 'Expected step text in output',
428
+ );
429
+ });
430
+ });
431
+
432
+ describe('generateComprehensionHTML — Phase 4: tour step list rendered', () => {
433
+ it('(c) tour-related markup (Start Tour button) present in output', () => {
434
+ const result = generateComprehensionHTML(phase4Data);
435
+ assert.ok(
436
+ result.includes('Start Tour') || result.includes('u-tour'),
437
+ 'Expected tour button or tour CSS class in output',
438
+ );
439
+ });
440
+
441
+ it('(c) tour builds on module data embedded in output', () => {
442
+ const result = generateComprehensionHTML(phase4Data);
443
+ // Tour step nodes come from modules embedded in D.modules
444
+ assert.ok(
445
+ result.includes('"AuthService"') || result.includes('AuthService'),
446
+ 'Expected module name embedded for tour',
447
+ );
448
+ });
449
+ });
450
+
451
+ describe('generateComprehensionHTML — Phase 4: self-containment guard extended', () => {
452
+ it('(d) no http(s):// with comprehension data (extended self-containment check)', () => {
453
+ const result = generateComprehensionHTML(phase4Data);
454
+ assert.ok(!result.includes('http://'), 'Found http:// in Phase 4 output');
455
+ assert.ok(!result.includes('https://'), 'Found https:// in Phase 4 output');
456
+ });
457
+
458
+ it('(d) no <link> tags with comprehension data', () => {
459
+ const result = generateComprehensionHTML(phase4Data);
460
+ assert.ok(!result.includes('<link'), 'Found <link> in Phase 4 output — not self-contained');
461
+ });
462
+
463
+ it('(d) no @import in Phase 4 CSS additions', () => {
464
+ const result = generateComprehensionHTML(phase4Data);
465
+ assert.ok(!result.includes('@import'), 'Found @import in Phase 4 output');
466
+ });
467
+ });
468
+
469
+ describe('generateComprehensionHTML — Phase 4: XSS — module name containing <script>', () => {
470
+ it('(e) module name containing <script> is escaped in output', () => {
471
+ const xssData = {
472
+ ...phase4Data,
473
+ modules: [
474
+ ...phase4Data.modules,
475
+ { id: 'evil', name: '<script>alert(1)</script>', layer: 'api', type: 'module', summary: 'xss test' },
476
+ ],
477
+ };
478
+ const result = generateComprehensionHTML(xssData);
479
+ // The raw string must not appear outside the safeJson JSON blob
480
+ const dataStart = result.indexOf('const D =');
481
+ const dataEnd = result.indexOf(';\n', dataStart);
482
+ const afterData = dataStart !== -1 && dataEnd !== -1 ? result.slice(dataEnd + 2) : '';
483
+ // After the JSON blob, no raw <script> from the node name should appear
484
+ assert.ok(
485
+ !afterData.includes('<script>alert(1)</script>'),
486
+ 'Raw <script> from module name found outside JSON blob — XSS!',
487
+ );
488
+ });
489
+ });
490
+
491
+ // ─── Phase 5a: Measure tab enrichment ───
492
+
493
+ const phase5Data = {
494
+ project: 'Phase5Test',
495
+ generated_at: '2026-06-20T10:00:00.000Z',
496
+ overview: {
497
+ total_sessions: 10,
498
+ avg_duration_min: 20,
499
+ total_tool_calls: 300,
500
+ total_skill_invocations: 50,
501
+ active_days: 7,
502
+ },
503
+ skillFrequency: [
504
+ { skill: 'cook', count: 15 },
505
+ { skill: 'scout', count: 8 },
506
+ { skill: 'plan', count: 5 },
507
+ ],
508
+ modelDistribution: [
509
+ { model: 'claude-sonnet-4-5', skill_count: 40 },
510
+ { model: 'claude-haiku-4-5', skill_count: 10 },
511
+ ],
512
+ skillHeatmap: {
513
+ heatmap: [
514
+ {
515
+ skill: 'cook',
516
+ total: 15,
517
+ days: [
518
+ { date: '2026-06-19', count: 3 },
519
+ { date: '2026-06-20', count: 2 },
520
+ ],
521
+ },
522
+ {
523
+ skill: 'scout',
524
+ total: 8,
525
+ days: [
526
+ { date: '2026-06-19', count: 1 },
527
+ { date: '2026-06-20', count: 0 },
528
+ ],
529
+ },
530
+ ],
531
+ dates: ['2026-06-19', '2026-06-20'],
532
+ maxCount: 3,
533
+ },
534
+ sessionTimeline: [
535
+ {
536
+ id: 's1',
537
+ date: '2026-06-20',
538
+ duration_min: 25,
539
+ tool_calls: 80,
540
+ skills_used: ['cook', 'scout', 'plan'],
541
+ primary_skill: 'cook',
542
+ chains: [['cook', 'scout']],
543
+ },
544
+ {
545
+ id: 's2',
546
+ date: '2026-06-19',
547
+ duration_min: 15,
548
+ tool_calls: 45,
549
+ skills_used: ['fix', 'debug'],
550
+ primary_skill: 'fix',
551
+ chains: [],
552
+ },
553
+ ],
554
+ skillChains: [
555
+ { chain: 'cook → scout → plan', count: 4 },
556
+ { chain: 'fix → debug', count: 2 },
557
+ ],
558
+ totalInstalledSkills: 64,
559
+ gates: [{ name: 'sentinel', fired: 5, blocked: 0 }],
560
+ };
561
+
562
+ describe('generateComprehensionHTML — Phase 5a: Measure tab model section', () => {
563
+ it('renders model distribution when data is present', () => {
564
+ const result = generateComprehensionHTML(phase5Data);
565
+ assert.ok(
566
+ result.includes('claude-sonnet-4-5') || result.includes('"claude-sonnet-4-5"'),
567
+ 'Expected model name in output',
568
+ );
569
+ });
570
+
571
+ it('renders skill-ROI section markers', () => {
572
+ const result = generateComprehensionHTML(phase5Data);
573
+ assert.ok(
574
+ result.includes('Skill ROI') || result.includes('roi-grid') || result.includes('skill-ROI'),
575
+ 'Expected Skill ROI section in output',
576
+ );
577
+ });
578
+
579
+ it('includes active vs dormant skill counts in the output', () => {
580
+ const result = generateComprehensionHTML(phase5Data);
581
+ // The roi section shows "N of M skills active"
582
+ assert.ok(
583
+ result.includes('active') && (result.includes('dormant') || result.includes('64')),
584
+ 'Expected active/dormant skill ROI info in output',
585
+ );
586
+ });
587
+
588
+ it('renders heatmap section when heatmap data present', () => {
589
+ const result = generateComprehensionHTML(phase5Data);
590
+ assert.ok(
591
+ result.includes('Heatmap') || result.includes('heatmap-wrap') || result.includes('hm-cell'),
592
+ 'Expected heatmap section in output',
593
+ );
594
+ });
595
+
596
+ it('renders session timeline section', () => {
597
+ const result = generateComprehensionHTML(phase5Data);
598
+ assert.ok(
599
+ result.includes('Timeline') || result.includes('timeline-list') || result.includes('timeline-item'),
600
+ 'Expected session timeline section in output',
601
+ );
602
+ });
603
+
604
+ it('does not produce NaN in measure output', () => {
605
+ const result = generateComprehensionHTML(phase5Data);
606
+ assert.ok(!result.includes('>NaN<'), 'NaN found in Measure HTML output');
607
+ });
608
+ });
609
+
610
+ // ─── Phase 5a: Improve tab — data-driven cards ───
611
+
612
+ describe('generateComprehensionHTML — Phase 5a: Improve tab with real data', () => {
613
+ it('renders improve-card when a real finding is derivable (blocks caught)', () => {
614
+ const dataWithBlocks = {
615
+ ...phase5Data,
616
+ gates: [{ name: 'sentinel', fired: 5, blocked: 3 }],
617
+ };
618
+ const result = generateComprehensionHTML(dataWithBlocks);
619
+ assert.ok(
620
+ result.includes('improve-card') || result.includes('block'),
621
+ 'Expected at least one improve card when blocks are caught',
622
+ );
623
+ });
624
+
625
+ it('renders improve card for repeated skill chain when count >= 3', () => {
626
+ const result = generateComprehensionHTML(phase5Data);
627
+ // skillChains has cook → scout → plan with count: 4
628
+ assert.ok(
629
+ result.includes('cook') || result.includes('Repeated') || result.includes('improve-card'),
630
+ 'Expected improve card referencing repeated chain',
631
+ );
632
+ });
633
+
634
+ it('renders honest empty state when no session data exists', () => {
635
+ const emptyData = {
636
+ project: 'EmptyProject',
637
+ overview: {},
638
+ skillFrequency: [],
639
+ modelDistribution: [],
640
+ skillHeatmap: { heatmap: [], dates: [], maxCount: 1 },
641
+ sessionTimeline: [],
642
+ skillChains: [],
643
+ gates: [],
644
+ totalInstalledSkills: 64,
645
+ };
646
+ const result = generateComprehensionHTML(emptyData);
647
+ assert.ok(
648
+ result.includes('Not enough session data') ||
649
+ result.includes('improve-content') ||
650
+ result.includes('Improvement'),
651
+ 'Expected honest empty state in Improve tab when no data',
652
+ );
653
+ // Must NOT contain generic boilerplate masquerading as a finding
654
+ assert.ok(!result.includes('improve-card sev-warn'), 'No warning cards should appear with zero data');
655
+ });
656
+
657
+ it('BLOAT_THRESHOLD constant is 120 (correctness guard)', () => {
658
+ // The bloat detection threshold is hard-coded in the embedded JS. Verify it's present.
659
+ const result = generateComprehensionHTML(phase5Data);
660
+ assert.ok(
661
+ result.includes('BLOAT_THRESHOLD') || result.includes('120'),
662
+ 'Expected BLOAT_THRESHOLD or 120 in embedded JS',
663
+ );
664
+ });
665
+ });
666
+
667
+ // ─── Phase 5a: Canvas keyboard accessibility ───
668
+
669
+ describe('generateComprehensionHTML — Phase 5a: canvas keyboard accessibility', () => {
670
+ it('canvas has tabindex="0" attribute', () => {
671
+ const result = generateComprehensionHTML(phase5Data);
672
+ assert.ok(
673
+ result.includes("tabindex','0'") || result.includes('tabindex=\\"0\\"') || result.includes("'tabindex','0'"),
674
+ 'Expected canvas tabindex="0"',
675
+ );
676
+ });
677
+
678
+ it('canvas aria-label updated to include keyboard navigation instructions', () => {
679
+ const result = generateComprehensionHTML(phase5Data);
680
+ assert.ok(
681
+ result.includes('Arrow keys') || result.includes('keyboard') || result.includes('aria-label'),
682
+ 'Expected keyboard navigation hint in canvas aria-label',
683
+ );
684
+ });
685
+
686
+ it('sr-only node list is present in understand tab output', () => {
687
+ const result = generateComprehensionHTML(phase5Data);
688
+ // The sr-only list is rendered via JS using allNodes — it's embedded in JS
689
+ assert.ok(
690
+ result.includes('sr-only') || result.includes('screen reader') || result.includes('srListWrap'),
691
+ 'Expected sr-only node list in understand tab',
692
+ );
693
+ });
694
+
695
+ it('output is still self-contained with phase5 canvas a11y additions', () => {
696
+ const result = generateComprehensionHTML(phase5Data);
697
+ assert.ok(!result.includes('http://'), 'Found http:// in phase 5 output');
698
+ assert.ok(!result.includes('https://'), 'Found https:// in phase 5 output');
699
+ assert.ok(!result.includes('<link'), 'Found <link> in phase 5 output');
700
+ assert.ok(!result.includes('@import'), 'Found @import in phase 5 output');
701
+ });
702
+
703
+ it('a name with & and < is NOT double-escaped when used in textContent paths', () => {
704
+ // The sr-only list items use .textContent (which the JS does), so
705
+ // the raw node name is embedded in JS source as a JS string (via safeJson),
706
+ // not as innerHTML. Verify the safeJson blob encodes it correctly.
707
+ const xssData = {
708
+ ...phase5Data,
709
+ modules: [{ id: 'evil', name: 'A&B <C>', layer: 'api', type: 'module', summary: 'test node' }],
710
+ };
711
+ const result = generateComprehensionHTML(xssData);
712
+ // In safeJson (JSON blob), & becomes & (literal in JSON string) and < becomes <
713
+ const dataStart = result.indexOf('const D =');
714
+ assert.ok(dataStart !== -1, 'Could not find const D =');
715
+ const dataSlice = result.slice(dataStart, dataStart + 8000);
716
+ // The < in node name must be escaped in the JSON blob
717
+ assert.ok(dataSlice.includes('\\u003c'), 'Expected \\u003c escape for < in node name JSON');
718
+ // But & inside JSON string is fine as literal & (JSON encodes it literally)
719
+ // The key test: raw </script> must not appear
720
+ assert.ok(!dataSlice.includes('</script>'), 'Raw </script> must not appear in data blob');
721
+ });
722
+ });
723
+
724
+ // ─── HIGH-2: XSS in Improve tab — skill chain containing <script> ───
725
+
726
+ describe('generateComprehensionHTML — XSS: skill chain with <script> in Improve card', () => {
727
+ it('skill chain containing <script>alert(1)</script> renders escaped in Improve card (HIGH-2)', () => {
728
+ // The Improve tab uses body.innerHTML = f.body for the "repeated workflow pattern" card.
729
+ // The chain text is interpolated via esc() — verify the output never contains a raw <script>.
730
+ const xssChainData = {
731
+ ...phase5Data,
732
+ skillChains: [
733
+ // count >= 3 triggers the finding card; chain contains a raw <script> tag
734
+ { chain: 'cook → <script>alert(1)</script> → plan', count: 3 },
735
+ ],
736
+ };
737
+ const result = generateComprehensionHTML(xssChainData);
738
+
739
+ // The raw <script> tag must NOT appear in the HTML outside the safeJson JSON blob.
740
+ // Anything after the data blob is the JS application code — raw tags there would be injectable.
741
+ const dataStart = result.indexOf('const D =');
742
+ const dataEnd = result.indexOf(';\n', dataStart);
743
+ const afterData = dataStart !== -1 && dataEnd !== -1 ? result.slice(dataEnd + 2) : result;
744
+
745
+ // The raw unescaped string must not appear in the rendered application JS
746
+ assert.ok(
747
+ !afterData.includes('<script>alert(1)</script>'),
748
+ 'Raw <script> from skill chain found in Improve card HTML — XSS via esc() bypass!',
749
+ );
750
+
751
+ // The escaped form SHOULD appear (esc() → &lt;script&gt;)
752
+ // We confirm the chain data IS present (so the card was rendered) — just escaped
753
+ assert.ok(
754
+ result.includes('cook') || result.includes('\\u003cscript\\u003e') || result.includes('&lt;script&gt;'),
755
+ 'Expected escaped chain content in output — esc() must have run',
756
+ );
757
+ });
758
+ });
759
+
760
+ // ─── Phase 5b: Tier gating tests ───
761
+
762
+ describe('generateComprehensionHTML — Phase 5b: Free tier — Govern renders upsell (not real data)', () => {
763
+ // Free tier: no hasPro, no hasBusiness (or hasBusiness explicitly false)
764
+ const freeTierData = {
765
+ project: 'FreeProject',
766
+ generated_at: '2026-06-20T10:00:00.000Z',
767
+ tier: 'free',
768
+ hasPro: false,
769
+ hasBusiness: false,
770
+ gates: [{ name: 'sentinel', fired: 3, blocked: 1 }],
771
+ compliance: [{ pack: '@rune-business/finance', obligation: 'MUST log all decisions', status: 'met' }],
772
+ decisions: [{ output: 'commit-abc', gate: 'sentinel', model: 'claude-sonnet', cost: 0.0042 }],
773
+ overview: { total_sessions: 5 },
774
+ skillFrequency: [{ skill: 'cook', count: 10 }],
775
+ };
776
+
777
+ it('embeds hasBusiness:false in the data JSON for Free tier', () => {
778
+ const result = generateComprehensionHTML(freeTierData);
779
+ // The tier data is embedded in const D = {...} — verify hasBusiness is false
780
+ assert.ok(result.includes('"hasBusiness":false'), 'Expected hasBusiness:false embedded in Free tier data JSON');
781
+ });
782
+
783
+ it('embeds hasPro:false in the data JSON for Free tier', () => {
784
+ const result = generateComprehensionHTML(freeTierData);
785
+ assert.ok(result.includes('"hasPro":false'), 'Expected hasPro:false embedded in Free tier data JSON');
786
+ });
787
+
788
+ it('upsell text about Business feature is present in JS source', () => {
789
+ const result = generateComprehensionHTML(freeTierData);
790
+ // The upsell description uses this text in textContent — it appears as a JS string literal.
791
+ assert.ok(
792
+ result.includes('Rune Business feature') || result.includes('Business tier'),
793
+ 'Expected upsell description text in Free tier govern JS source',
794
+ );
795
+ });
796
+
797
+ it('upsell describes what Govern provides (honest description)', () => {
798
+ const result = generateComprehensionHTML(freeTierData);
799
+ // The upsell must mention gate ledger or compliance — real value description
800
+ assert.ok(
801
+ result.includes('gate') || result.includes('compliance') || result.includes('Business'),
802
+ 'Upsell should describe the Govern feature value',
803
+ );
804
+ });
805
+
806
+ it('still passes the no-https guard (no external URLs in upsell)', () => {
807
+ const result = generateComprehensionHTML(freeTierData);
808
+ assert.ok(!result.includes('https://'), 'Found https:// in free-tier upsell — self-containment broken');
809
+ });
810
+
811
+ it('still passes the no-http guard', () => {
812
+ const result = generateComprehensionHTML(freeTierData);
813
+ assert.ok(!result.includes('http://'), 'Found http:// in free-tier output');
814
+ });
815
+ });
816
+
817
+ describe('generateComprehensionHTML — Phase 5b: Business tier — Govern renders real panels', () => {
818
+ const businessTierData = {
819
+ project: 'BusinessProject',
820
+ generated_at: '2026-06-20T10:00:00.000Z',
821
+ tier: 'business',
822
+ hasPro: true,
823
+ hasBusiness: true,
824
+ gates: [{ name: 'sentinel', fired: 5, blocked: 2 }],
825
+ compliance: [{ pack: '@rune-business/finance', obligation: 'MUST log all decisions', status: 'met' }],
826
+ decisions: [],
827
+ overview: { total_sessions: 8 },
828
+ skillFrequency: [{ skill: 'cook', count: 15 }],
829
+ };
830
+
831
+ it('renders Governance Ledger for Business tier', () => {
832
+ const result = generateComprehensionHTML(businessTierData);
833
+ assert.ok(
834
+ result.includes('Governance Ledger') || result.includes('govern-content'),
835
+ 'Expected Governance Ledger in Business tier output',
836
+ );
837
+ });
838
+
839
+ it('renders Compliance Coverage for Business tier', () => {
840
+ const result = generateComprehensionHTML(businessTierData);
841
+ assert.ok(
842
+ result.includes('Compliance Coverage') || result.includes('cov-pack'),
843
+ 'Expected Compliance Coverage in Business tier output',
844
+ );
845
+ });
846
+
847
+ it('embeds hasBusiness:true in the data JSON for Business tier', () => {
848
+ const result = generateComprehensionHTML(businessTierData);
849
+ // The key check: D.hasBusiness is embedded as true for Business tier
850
+ assert.ok(result.includes('"hasBusiness":true'), 'Expected hasBusiness:true in Business tier data JSON');
851
+ });
852
+
853
+ it('embeds tier:"business" in the data JSON', () => {
854
+ const result = generateComprehensionHTML(businessTierData);
855
+ assert.ok(result.includes('"tier":"business"'), 'Expected tier:business in Business tier data JSON');
856
+ });
857
+ });
858
+
859
+ describe('generateComprehensionHTML — Phase 5b: Pro tier — My Lens persona present', () => {
860
+ const proTierData = {
861
+ project: 'ProProject',
862
+ generated_at: '2026-06-20T10:00:00.000Z',
863
+ tier: 'pro',
864
+ hasPro: true,
865
+ hasBusiness: false,
866
+ overview: { total_sessions: 5 },
867
+ skillFrequency: [{ skill: 'cook', count: 10 }],
868
+ gates: [{ name: 'sentinel', fired: 3, blocked: 0 }],
869
+ };
870
+
871
+ it('renders the My Lens persona button when hasPro is true', () => {
872
+ const result = generateComprehensionHTML(proTierData);
873
+ assert.ok(
874
+ result.includes('My Lens') || result.includes('data-persona="mylens"') || result.includes('mylens'),
875
+ 'Expected My Lens persona option in Pro tier output',
876
+ );
877
+ });
878
+
879
+ it('My Lens persona button is keyboard-operable (aria-pressed attribute present)', () => {
880
+ const result = generateComprehensionHTML(proTierData);
881
+ // The My Lens button renders with aria-pressed
882
+ assert.ok(
883
+ result.includes('aria-pressed') && (result.includes('mylens') || result.includes('My Lens')),
884
+ 'Expected aria-pressed on My Lens persona button',
885
+ );
886
+ });
887
+ });
888
+
889
+ describe('generateComprehensionHTML — Phase 5b: Free tier — My Lens persona absent', () => {
890
+ const freeTierNoProData = {
891
+ project: 'FreeNoProProject',
892
+ tier: 'free',
893
+ hasPro: false,
894
+ hasBusiness: false,
895
+ };
896
+
897
+ it('does NOT render My Lens persona button when hasPro is false', () => {
898
+ const result = generateComprehensionHTML(freeTierNoProData);
899
+ // The "My Lens" option must not appear for free users — Pro-only feature
900
+ assert.ok(!result.includes('data-persona="mylens"'), 'My Lens persona button should not appear in Free tier');
901
+ });
902
+ });
903
+
904
+ describe('generateComprehensionHTML — Phase 5b: default data (empty) uses free tier defaults', () => {
905
+ it('empty data defaults to free tier — hasBusiness:false, no My Lens button', () => {
906
+ const result = generateComprehensionHTML({});
907
+ // Must not show My Lens button (hasPro=false by default)
908
+ assert.ok(!result.includes('data-persona="mylens"'), 'My Lens must not appear in default (free) tier output');
909
+ // hasBusiness defaults to false — verify it's embedded correctly
910
+ assert.ok(result.includes('"hasBusiness":false'), 'Expected hasBusiness:false in default data JSON');
911
+ // hasPro defaults to false
912
+ assert.ok(result.includes('"hasPro":false'), 'Expected hasPro:false in default data JSON');
913
+ // tier defaults to 'free'
914
+ assert.ok(result.includes('"tier":"free"'), 'Expected tier:free in default data JSON');
915
+ });
916
+ });