@mifort-solutions/qmetrix 1.0.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.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/audit-structure.mjs +103 -0
- package/src/bundle-codebase.mjs +626 -0
- package/src/check-images.mjs +125 -0
- package/src/coverage/clean.mjs +32 -0
- package/src/coverage/merge-istanbul.mjs +161 -0
- package/src/coverage/next-start-cov.mjs +38 -0
- package/src/coverage/report-global.mjs +90 -0
- package/src/coverage/report-suite.mjs +148 -0
- package/src/coverage/src-filter.mjs +50 -0
- package/src/dashboard/collectors/code.mjs +104 -0
- package/src/dashboard/collectors/composition-meta.mjs +295 -0
- package/src/dashboard/collectors/composition-transitions.mjs +0 -0
- package/src/dashboard/collectors/composition.mjs +360 -0
- package/src/dashboard/collectors/coverage.mjs +98 -0
- package/src/dashboard/collectors/deps.mjs +187 -0
- package/src/dashboard/collectors/entities.mjs +147 -0
- package/src/dashboard/collectors/graph.mjs +105 -0
- package/src/dashboard/collectors/lint.mjs +117 -0
- package/src/dashboard/collectors/routing.mjs +82 -0
- package/src/dashboard/collectors/security.mjs +182 -0
- package/src/dashboard/collectors/storybook.mjs +33 -0
- package/src/dashboard/config.mjs +15 -0
- package/src/dashboard/render/client.mjs +178 -0
- package/src/dashboard/render/components.mjs +247 -0
- package/src/dashboard/render/composition.mjs +192 -0
- package/src/dashboard/render/styles.mjs +217 -0
- package/src/dashboard/render/template.mjs +283 -0
- package/src/dashboard/utils/exec.mjs +29 -0
- package/src/dashboard/utils/format.mjs +32 -0
- package/src/dashboard/utils/fs.mjs +48 -0
- package/src/e2e-server-guard.mjs +283 -0
- package/src/optimize-images.mjs +231 -0
- package/src/quality-dashboard.mjs +291 -0
- package/src/security-scan.mjs +267 -0
- package/src/test-outline.mjs +98 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side scripts embedded verbatim into the report.
|
|
3
|
+
*
|
|
4
|
+
* These run in the browser, not in Node — they are exported as plain strings and
|
|
5
|
+
* dropped inside <script> tags by the template. Keep them self-contained (no
|
|
6
|
+
* server-side interpolation); the only data they read is the #graph-data JSON
|
|
7
|
+
* the template injects separately.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Force-directed file-graph canvas (interactive: drag nodes, hover for path). */
|
|
11
|
+
export const GRAPH_SCRIPT = `(function(){
|
|
12
|
+
const data = JSON.parse(document.getElementById('graph-data').textContent);
|
|
13
|
+
const canvas = document.getElementById('graph');
|
|
14
|
+
const dpr = window.devicePixelRatio || 1;
|
|
15
|
+
function size(){ canvas.width = canvas.clientWidth*dpr; canvas.height = canvas.clientHeight*dpr; }
|
|
16
|
+
size(); window.addEventListener('resize', size);
|
|
17
|
+
// The canvas lives inside a collapsed <details>; clientWidth is 0 until opened.
|
|
18
|
+
// Re-measure (and re-seed node positions once) the first time it's revealed.
|
|
19
|
+
(function(){ const det = canvas.closest('details'); if(!det) return;
|
|
20
|
+
let seeded=false;
|
|
21
|
+
det.addEventListener('toggle', ()=>{ if(!det.open) return; size();
|
|
22
|
+
if(seeded) return; seeded=true;
|
|
23
|
+
const cw=canvas.clientWidth||640;
|
|
24
|
+
for(let i=0;i<nodes.length;i++){ nodes[i].x=Math.cos(i/nodes.length*2*Math.PI)*220+cw/2; nodes[i].y=Math.sin(i/nodes.length*2*Math.PI)*180+230; nodes[i].vx=nodes[i].vy=0; }
|
|
25
|
+
});
|
|
26
|
+
})();
|
|
27
|
+
const ctx = canvas.getContext('2d');
|
|
28
|
+
const palette = ['#1377FE','#22c55e','#f59e0b','#a855f7','#ec4899','#06b6d4','#ef4444','#84cc16','#eab308'];
|
|
29
|
+
const groups = [...new Set(data.nodes.map(n=>n.group))];
|
|
30
|
+
const colorOf = g => palette[groups.indexOf(g)%palette.length];
|
|
31
|
+
document.getElementById('legend').innerHTML = groups.map(g=>'<span><i style="--c:'+colorOf(g)+'"></i>'+g+'</span>').join('');
|
|
32
|
+
|
|
33
|
+
const N = data.nodes.length;
|
|
34
|
+
const nodes = data.nodes.map((n,i)=>({...n,
|
|
35
|
+
x: Math.cos(i/N*2*Math.PI)*220 + (canvas.clientWidth/2),
|
|
36
|
+
y: Math.sin(i/N*2*Math.PI)*180 + 230, vx:0, vy:0,
|
|
37
|
+
r: 4 + Math.min(8, (n.fanIn||0))}));
|
|
38
|
+
const edges = data.edges;
|
|
39
|
+
let drag=null, mouse={x:0,y:0}, hover=null;
|
|
40
|
+
|
|
41
|
+
function tick(){
|
|
42
|
+
const cx=canvas.clientWidth/2, cy=230;
|
|
43
|
+
for(let i=0;i<N;i++){
|
|
44
|
+
const a=nodes[i]; a.vx += (cx-a.x)*0.0008; a.vy += (cy-a.y)*0.0008;
|
|
45
|
+
for(let j=i+1;j<N;j++){
|
|
46
|
+
const b=nodes[j]; let dx=a.x-b.x, dy=a.y-b.y; let d2=dx*dx+dy*dy||0.01;
|
|
47
|
+
const f=380/d2; const d=Math.sqrt(d2);
|
|
48
|
+
dx/=d; dy/=d; a.vx+=dx*f; a.vy+=dy*f; b.vx-=dx*f; b.vy-=dy*f;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for(const e of edges){
|
|
52
|
+
const a=nodes[e.source], b=nodes[e.target];
|
|
53
|
+
let dx=b.x-a.x, dy=b.y-a.y; const d=Math.sqrt(dx*dx+dy*dy)||0.01;
|
|
54
|
+
const f=(d-70)*0.01; dx/=d; dy/=d;
|
|
55
|
+
a.vx+=dx*f; a.vy+=dy*f; b.vx-=dx*f; b.vy-=dy*f;
|
|
56
|
+
}
|
|
57
|
+
for(const n of nodes){ if(n===drag)continue; n.vx*=0.86; n.vy*=0.86; n.x+=n.vx; n.y+=n.vy; }
|
|
58
|
+
}
|
|
59
|
+
function draw(){
|
|
60
|
+
ctx.save(); ctx.scale(dpr,dpr); ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
|
|
61
|
+
ctx.lineWidth=0.6; ctx.strokeStyle='rgba(120,150,200,0.18)';
|
|
62
|
+
for(const e of edges){ const a=nodes[e.source], b=nodes[e.target];
|
|
63
|
+
ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); }
|
|
64
|
+
for(const n of nodes){
|
|
65
|
+
ctx.beginPath(); ctx.fillStyle=colorOf(n.group); ctx.globalAlpha = hover&&hover!==n?0.5:1;
|
|
66
|
+
ctx.arc(n.x,n.y,n.r,0,7); ctx.fill(); ctx.globalAlpha=1;
|
|
67
|
+
}
|
|
68
|
+
if(hover){ ctx.fillStyle='#e6edf7'; ctx.font='12px ui-monospace,monospace';
|
|
69
|
+
const t=hover.label+' (in '+hover.fanIn+' / out '+hover.fanOut+')';
|
|
70
|
+
const w=ctx.measureText(t).width+12; let tx=hover.x+10, ty=hover.y-10;
|
|
71
|
+
if(tx+w>canvas.clientWidth) tx=hover.x-w-10;
|
|
72
|
+
ctx.fillStyle='rgba(10,18,36,.92)'; ctx.fillRect(tx,ty-13,w,18);
|
|
73
|
+
ctx.fillStyle='#e6edf7'; ctx.fillText(t,tx+6,ty); }
|
|
74
|
+
ctx.restore();
|
|
75
|
+
}
|
|
76
|
+
function loop(){ tick(); draw(); requestAnimationFrame(loop); } loop();
|
|
77
|
+
|
|
78
|
+
function at(mx,my){ let best=null,bd=144; for(const n of nodes){ const d=(n.x-mx)**2+(n.y-my)**2; if(d<bd){bd=d;best=n;} } return best; }
|
|
79
|
+
canvas.addEventListener('mousemove',ev=>{ const r=canvas.getBoundingClientRect(); mouse={x:ev.clientX-r.left,y:ev.clientY-r.top};
|
|
80
|
+
if(drag){ drag.x=mouse.x; drag.y=mouse.y; drag.vx=drag.vy=0; } else hover=at(mouse.x,mouse.y); });
|
|
81
|
+
canvas.addEventListener('mousedown',ev=>{ const r=canvas.getBoundingClientRect(); drag=at(ev.clientX-r.left,ev.clientY-r.top); canvas.style.cursor='grabbing'; });
|
|
82
|
+
window.addEventListener('mouseup',()=>{ drag=null; canvas.style.cursor='grab'; });
|
|
83
|
+
})();`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Component composition — expand/collapse-all for the nested-rectangle view.
|
|
87
|
+
* The boxes themselves are static HTML (<details> per box); this only wires the
|
|
88
|
+
* two "Expand all / Collapse all" buttons to flip every box's metadata panel.
|
|
89
|
+
*/
|
|
90
|
+
export const COMPOSITION_SCRIPT = `(function(){
|
|
91
|
+
const btns = document.querySelectorAll('[data-comp-toggle]');
|
|
92
|
+
if(!btns.length) return;
|
|
93
|
+
btns.forEach(function(btn){
|
|
94
|
+
btn.addEventListener('click', function(){
|
|
95
|
+
const open = btn.getAttribute('data-comp-toggle') === 'open';
|
|
96
|
+
document.querySelectorAll('.comp-tree details.comp-card').forEach(function(d){ d.open = open; });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
})();`;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Component composition — page-flow arrows. Draws an SVG cubic-bezier between the
|
|
103
|
+
* mini browser windows for every transition edge (indices into the .cflow-node
|
|
104
|
+
* list, injected as #comp-flow-data). Coordinates are taken from live layout
|
|
105
|
+
* (getBoundingClientRect relative to the #cflow box) and recomputed on resize, so
|
|
106
|
+
* the arrows stay glued to the nodes regardless of wrapping.
|
|
107
|
+
*/
|
|
108
|
+
export const COMPOSITION_FLOW_SCRIPT = `(function(){
|
|
109
|
+
const cflow = document.getElementById('cflow');
|
|
110
|
+
const svg = document.getElementById('cflow-svg');
|
|
111
|
+
const dataEl = document.getElementById('comp-flow-data');
|
|
112
|
+
if(!cflow || !svg || !dataEl) return;
|
|
113
|
+
let edges = [];
|
|
114
|
+
try { edges = JSON.parse(dataEl.textContent) || []; } catch(e){ return; }
|
|
115
|
+
const nodes = cflow.querySelectorAll('.cflow-node');
|
|
116
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
117
|
+
function draw(){
|
|
118
|
+
[].slice.call(svg.querySelectorAll('path.cflow-edge')).forEach(function(p){ p.remove(); });
|
|
119
|
+
const base = cflow.getBoundingClientRect();
|
|
120
|
+
svg.setAttribute('width', cflow.scrollWidth);
|
|
121
|
+
svg.setAttribute('height', cflow.scrollHeight);
|
|
122
|
+
edges.forEach(function(e){
|
|
123
|
+
const a = nodes[e[0]], b = nodes[e[1]];
|
|
124
|
+
if(!a || !b) return;
|
|
125
|
+
const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
|
|
126
|
+
const x1 = ra.left - base.left + ra.width/2;
|
|
127
|
+
const x2 = rb.left - base.left + rb.width/2;
|
|
128
|
+
let y1, y2, d;
|
|
129
|
+
const dy = Math.max(26, Math.abs((rb.top+rb.bottom)/2 - (ra.top+ra.bottom)/2)/2);
|
|
130
|
+
if(rb.top >= ra.bottom - 1){ // forward edge: bottom of a → top of b
|
|
131
|
+
y1 = ra.bottom - base.top; y2 = rb.top - base.top;
|
|
132
|
+
d = 'M'+x1+','+y1+' C'+x1+','+(y1+dy)+' '+x2+','+(y2-dy)+' '+x2+','+y2;
|
|
133
|
+
} else { // back / same-layer edge: route around the side
|
|
134
|
+
y1 = ra.top - base.top; y2 = rb.bottom - base.top;
|
|
135
|
+
const bend = x2 >= x1 ? 70 : -70;
|
|
136
|
+
d = 'M'+x1+','+y1+' C'+(x1+bend)+','+(y1-dy)+' '+(x2+bend)+','+(y2+dy)+' '+x2+','+y2;
|
|
137
|
+
}
|
|
138
|
+
const path = document.createElementNS(NS, 'path');
|
|
139
|
+
path.setAttribute('d', d);
|
|
140
|
+
path.setAttribute('class', 'cflow-edge');
|
|
141
|
+
path.setAttribute('marker-end', 'url(#cflow-arrow)');
|
|
142
|
+
svg.appendChild(path);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
draw();
|
|
146
|
+
window.addEventListener('resize', draw);
|
|
147
|
+
setTimeout(draw, 80);
|
|
148
|
+
})();`;
|
|
149
|
+
|
|
150
|
+
/** Dependency-table filter (segment buttons + checkboxes + search). */
|
|
151
|
+
export const DEP_FILTER_SCRIPT = `(function(){
|
|
152
|
+
const table = document.getElementById('dep-rows');
|
|
153
|
+
if(!table) return;
|
|
154
|
+
const rows = [...table.querySelectorAll('tbody tr')];
|
|
155
|
+
const state = { type:'all', outdated:false, vuln:false, q:'' };
|
|
156
|
+
const count = document.getElementById('dep-count');
|
|
157
|
+
function apply(){
|
|
158
|
+
let shown=0;
|
|
159
|
+
for(const r of rows){
|
|
160
|
+
const ok = (state.type==='all' || r.dataset.type===state.type)
|
|
161
|
+
&& (!state.outdated || r.dataset.outdated==='1')
|
|
162
|
+
&& (!state.vuln || r.dataset.vuln==='1')
|
|
163
|
+
&& (!state.q || r.dataset.name.includes(state.q));
|
|
164
|
+
r.style.display = ok ? '' : 'none';
|
|
165
|
+
if(ok) shown++;
|
|
166
|
+
}
|
|
167
|
+
if(count) count.textContent = 'showing '+shown+' of '+rows.length;
|
|
168
|
+
}
|
|
169
|
+
document.querySelectorAll('#dep-filters [data-ftype]').forEach(b=>b.addEventListener('click',()=>{
|
|
170
|
+
state.type=b.dataset.ftype;
|
|
171
|
+
document.querySelectorAll('#dep-filters [data-ftype]').forEach(x=>x.classList.toggle('on',x===b));
|
|
172
|
+
apply();
|
|
173
|
+
}));
|
|
174
|
+
const bind=(id,key)=>{ const e=document.getElementById(id); if(e) e.addEventListener('change',()=>{ state[key]=e.checked; apply(); }); };
|
|
175
|
+
bind('f-outdated','outdated'); bind('f-vuln','vuln');
|
|
176
|
+
const q=document.getElementById('f-q'); if(q) q.addEventListener('input',()=>{ state.q=q.value.trim().toLowerCase(); apply(); });
|
|
177
|
+
apply();
|
|
178
|
+
})();`;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/** Reusable HTML fragment builders (cards, gauges, expandable trees, ER boxes). */
|
|
2
|
+
import { esc, pct } from '../utils/format.mjs';
|
|
3
|
+
|
|
4
|
+
const SEV_COLORS = {
|
|
5
|
+
critical: '#dc2626',
|
|
6
|
+
high: '#ea580c',
|
|
7
|
+
medium: '#d97706',
|
|
8
|
+
low: '#0891b2',
|
|
9
|
+
note: '#64748b',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function sevBadges(sev) {
|
|
13
|
+
return (
|
|
14
|
+
Object.entries(SEV_COLORS)
|
|
15
|
+
.filter(([k]) => (sev[k] || 0) > 0)
|
|
16
|
+
.map(([k, c]) => `<span class="sev" style="--c:${c}">${sev[k]} ${k}</span>`)
|
|
17
|
+
.join(' ') || '<span class="sev" style="--c:#16a34a">0 findings</span>'
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Per-finding "why it's flagged" list (Snyk/CodeQL/etc.), severity-led. */
|
|
22
|
+
function findingsList(reports) {
|
|
23
|
+
const all = reports.flatMap((r) => r.findings || []);
|
|
24
|
+
if (!all.length) {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
const rows = all
|
|
28
|
+
.map((f) => {
|
|
29
|
+
const c = SEV_COLORS[f.severity] || SEV_COLORS.note;
|
|
30
|
+
const where = f.where ? ` <code>${esc(f.where)}</code>` : '';
|
|
31
|
+
const detail = f.detail ? `<div class="fd muted">${esc(f.detail)}</div>` : '';
|
|
32
|
+
return `<li><span class="sev" style="--c:${c}">${esc(f.severity)}</span> <span class="fr">${esc(f.reason || f.rule || '—')}</span>${where}${detail}</li>`;
|
|
33
|
+
})
|
|
34
|
+
.join('');
|
|
35
|
+
return `<details open class="sec-findings"><summary>Why these are flagged (${all.length})</summary><ul class="findings">${rows}</ul></details>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function scannerCard(s) {
|
|
39
|
+
let status, body;
|
|
40
|
+
if (s.reports && s.reports.length) {
|
|
41
|
+
const totals = { critical: 0, high: 0, medium: 0, low: 0, note: 0 };
|
|
42
|
+
let total = 0;
|
|
43
|
+
for (const r of s.reports) {
|
|
44
|
+
total += r.total || 0;
|
|
45
|
+
for (const k of Object.keys(totals)) {
|
|
46
|
+
totals[k] += r.sev?.[k] || 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
status = `<span class="badge ok">local report · ${total} findings</span>`;
|
|
50
|
+
body = `
|
|
51
|
+
<div class="sevrow">${sevBadges(totals)}</div>
|
|
52
|
+
<table class="mini"><thead><tr><th>Artifact</th><th>Driver</th><th class="r">Findings</th></tr></thead><tbody>
|
|
53
|
+
${s.reports.map((r) => `<tr><td><code>${esc(r.file)}</code></td><td>${esc(r.driver || '—')}</td><td class="r">${r.total}</td></tr>`).join('')}
|
|
54
|
+
</tbody></table>
|
|
55
|
+
${findingsList(s.reports)}`;
|
|
56
|
+
} else if (s.report) {
|
|
57
|
+
status = `<span class="badge ok">local scan</span>`;
|
|
58
|
+
body = `<p class="muted">Project key <code>${esc(s.projectKey || '')}</code> — last local scan report found.</p>`;
|
|
59
|
+
} else {
|
|
60
|
+
status = `<span class="badge warn">configured · runs in CI</span>`;
|
|
61
|
+
body = `<p class="muted">No local SARIF artifact. This scanner runs server-side in GitHub Actions${
|
|
62
|
+
s.workflow ? '' : ' (workflow missing!)'
|
|
63
|
+
} and publishes to its dashboard. Download a run artifact next to this script to surface findings here.</p>`;
|
|
64
|
+
}
|
|
65
|
+
const links = [
|
|
66
|
+
s.ci ? `<a href="${s.ci}" target="_blank" rel="noopener">GitHub Security ↗</a>` : null,
|
|
67
|
+
s.dashboard
|
|
68
|
+
? `<a href="${s.dashboard}" target="_blank" rel="noopener">Vendor dashboard ↗</a>`
|
|
69
|
+
: null,
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(' · ');
|
|
73
|
+
return `
|
|
74
|
+
<div class="card scanner">
|
|
75
|
+
<div class="card-h"><h3>${esc(s.name)}</h3>${status}</div>
|
|
76
|
+
<p class="tool">${esc(s.tool)}</p>
|
|
77
|
+
${body}
|
|
78
|
+
<div class="links">${links}</div>
|
|
79
|
+
</div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function bar(label, value) {
|
|
83
|
+
const v = value == null ? 0 : value;
|
|
84
|
+
const c = v >= 80 ? '#16a34a' : v >= 50 ? '#d97706' : '#dc2626';
|
|
85
|
+
return `
|
|
86
|
+
<div class="gauge">
|
|
87
|
+
<div class="gauge-h"><span>${label}</span><strong>${pct(value)}</strong></div>
|
|
88
|
+
<div class="track"><div class="fill" style="width:${v}%;background:${c}"></div></div>
|
|
89
|
+
</div>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* ── file relationship tree (grouped by folder, expandable) ── */
|
|
93
|
+
export function fileTreeHtml(graphData) {
|
|
94
|
+
const { nodes, edges } = graphData;
|
|
95
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
96
|
+
const importsOf = new Map();
|
|
97
|
+
const importedByOf = new Map();
|
|
98
|
+
const push = (map, k, v) => (map.get(k) || map.set(k, []).get(k)).push(v);
|
|
99
|
+
for (const e of edges) {
|
|
100
|
+
push(importsOf, e.source, e.target);
|
|
101
|
+
push(importedByOf, e.target, e.source);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const root = { dirs: new Map(), files: [] };
|
|
105
|
+
for (const n of nodes) {
|
|
106
|
+
const parts = n.label.split('/');
|
|
107
|
+
parts.pop();
|
|
108
|
+
let cur = root;
|
|
109
|
+
for (const p of parts) {
|
|
110
|
+
if (!cur.dirs.has(p)) {
|
|
111
|
+
cur.dirs.set(p, { dirs: new Map(), files: [] });
|
|
112
|
+
}
|
|
113
|
+
cur = cur.dirs.get(p);
|
|
114
|
+
}
|
|
115
|
+
cur.files.push(n);
|
|
116
|
+
}
|
|
117
|
+
const count = (node) => {
|
|
118
|
+
let c = node.files.length;
|
|
119
|
+
for (const d of node.dirs.values()) {
|
|
120
|
+
c += count(d);
|
|
121
|
+
}
|
|
122
|
+
return c;
|
|
123
|
+
};
|
|
124
|
+
const labels = (ids) =>
|
|
125
|
+
(ids || [])
|
|
126
|
+
.map((id) => byId.get(id))
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
129
|
+
const fileRow = (n) => {
|
|
130
|
+
const fname = n.label.split('/').pop();
|
|
131
|
+
const badges = `<span class="fan in" title="imported by ${n.fanIn} module(s)">in ${n.fanIn}</span><span class="fan out" title="imports ${n.fanOut} module(s)">out ${n.fanOut}</span>`;
|
|
132
|
+
const imps = labels(importsOf.get(n.id));
|
|
133
|
+
const impd = labels(importedByOf.get(n.id));
|
|
134
|
+
if (!imps.length && !impd.length) {
|
|
135
|
+
return `<div class="tree-file"><span class="ico">📄</span><code>${esc(fname)}</code> ${badges}</div>`;
|
|
136
|
+
}
|
|
137
|
+
const rel = (head, arr) =>
|
|
138
|
+
arr.length
|
|
139
|
+
? `<div class="rel"><span class="muted">${head}</span> ${arr.map((x) => `<code>${esc(x.label)}</code>`).join(' ')}</div>`
|
|
140
|
+
: '';
|
|
141
|
+
return `<details class="tree-file det"><summary><span class="ico">📄</span><code>${esc(fname)}</code> ${badges}</summary><div class="tree-body">${rel('imports →', imps)}${rel('imported by ←', impd)}</div></details>`;
|
|
142
|
+
};
|
|
143
|
+
const dirRow = (node, name, depth) => {
|
|
144
|
+
const inner =
|
|
145
|
+
[...node.dirs.entries()]
|
|
146
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
147
|
+
.map(([dn, dnode]) => dirRow(dnode, dn, depth + 1))
|
|
148
|
+
.join('') +
|
|
149
|
+
node.files
|
|
150
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
151
|
+
.map(fileRow)
|
|
152
|
+
.join('');
|
|
153
|
+
if (name == null) {
|
|
154
|
+
return inner;
|
|
155
|
+
}
|
|
156
|
+
return `<details class="tree-dir"${depth < 1 ? ' open' : ''}><summary><span class="ico">📁</span>${esc(name)} <span class="muted">(${count(node)})</span></summary><div class="tree-body">${inner}</div></details>`;
|
|
157
|
+
};
|
|
158
|
+
return dirRow(root, null, 0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ── Next.js routing tree (expandable) ── */
|
|
162
|
+
export function routingHtml(routing) {
|
|
163
|
+
if (!routing.available) {
|
|
164
|
+
return `<p class="muted">No <code>src/app</code> directory found.</p>`;
|
|
165
|
+
}
|
|
166
|
+
const kindColor = {
|
|
167
|
+
page: '#22c55e',
|
|
168
|
+
layout: '#1377FE',
|
|
169
|
+
route: '#a855f7',
|
|
170
|
+
loading: '#06b6d4',
|
|
171
|
+
error: '#ef4444',
|
|
172
|
+
'global-error': '#ef4444',
|
|
173
|
+
'not-found': '#f59e0b',
|
|
174
|
+
template: '#64748b',
|
|
175
|
+
default: '#64748b',
|
|
176
|
+
};
|
|
177
|
+
const node = (n, depth, isRoot) => {
|
|
178
|
+
const segHtml = isRoot
|
|
179
|
+
? `<span class="seg">app</span> <code class="url">/</code>`
|
|
180
|
+
: n.group
|
|
181
|
+
? `<span class="seg group">${esc(n.seg)}</span>`
|
|
182
|
+
: n.dynamic
|
|
183
|
+
? `<span class="seg dyn">${esc(n.seg)}</span>`
|
|
184
|
+
: `<span class="seg">${esc(n.seg)}</span>`;
|
|
185
|
+
const urlChip =
|
|
186
|
+
!isRoot && (n.kinds.includes('page') || n.kinds.includes('route'))
|
|
187
|
+
? ` <code class="url">${esc(n.url || '/')}</code>`
|
|
188
|
+
: '';
|
|
189
|
+
const badges = n.kinds
|
|
190
|
+
.map((k) => `<span class="rt" style="--c:${kindColor[k] || '#64748b'}">${k}</span>`)
|
|
191
|
+
.join(' ');
|
|
192
|
+
const head = `${segHtml}${urlChip} ${badges}`;
|
|
193
|
+
if (!n.children.length) {
|
|
194
|
+
return `<div class="rt-leaf">${head}</div>`;
|
|
195
|
+
}
|
|
196
|
+
return `<details class="rt-dir"${depth < 2 ? ' open' : ''}><summary>${head}</summary><div class="tree-body">${n.children.map((c) => node(c, depth + 1, false)).join('')}</div></details>`;
|
|
197
|
+
};
|
|
198
|
+
return node(routing.root, 0, true);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* ── data model / ER diagram ── */
|
|
202
|
+
export function entitiesHtml(entities) {
|
|
203
|
+
if (!entities.available || (!entities.tables.length && !entities.buckets.length)) {
|
|
204
|
+
return `<p class="muted">No SQL migrations found under <code>supabase/migrations</code>.</p>`;
|
|
205
|
+
}
|
|
206
|
+
const keyBadge = (c) =>
|
|
207
|
+
c.pk
|
|
208
|
+
? `<span class="kb pk" title="primary key">PK</span>`
|
|
209
|
+
: c.fk
|
|
210
|
+
? `<span class="kb fk" title="references ${esc(c.fk.table)}">FK</span>`
|
|
211
|
+
: '';
|
|
212
|
+
const tableBox = (t) => `
|
|
213
|
+
<div class="er-table">
|
|
214
|
+
<div class="er-h">${esc(t.name)}${t.rls ? `<span class="er-rls" title="row level security enabled">RLS</span>` : ''}</div>
|
|
215
|
+
<table class="er-cols"><tbody>
|
|
216
|
+
${t.columns
|
|
217
|
+
.map(
|
|
218
|
+
(c) => `<tr>
|
|
219
|
+
<td class="er-k">${keyBadge(c)}</td>
|
|
220
|
+
<td class="er-c">${esc(c.name)}${c.notnull && !c.pk ? '<span class="req" title="NOT NULL">*</span>' : ''}</td>
|
|
221
|
+
<td class="er-t">${esc(c.type)}</td>
|
|
222
|
+
</tr>`,
|
|
223
|
+
)
|
|
224
|
+
.join('')}
|
|
225
|
+
</tbody></table>
|
|
226
|
+
${t.indexes.length ? `<div class="er-idx">${t.indexes.map((i) => `<span title="(${esc(i.cols)})">⚲ ${esc(i.name)}</span>`).join(' ')}</div>` : ''}
|
|
227
|
+
</div>`;
|
|
228
|
+
const bucketBox = (b) => `
|
|
229
|
+
<div class="er-table bucket">
|
|
230
|
+
<div class="er-h">🗄 ${esc(b.name)}<span class="er-rls ${b.public ? 'pub' : ''}">${b.public ? 'public' : 'private'}</span></div>
|
|
231
|
+
<div class="er-bk">storage bucket</div>
|
|
232
|
+
</div>`;
|
|
233
|
+
const rels = entities.tables.flatMap((t) =>
|
|
234
|
+
t.columns
|
|
235
|
+
.filter((c) => c.fk)
|
|
236
|
+
.map(
|
|
237
|
+
(c) =>
|
|
238
|
+
`<code>${esc(t.name)}.${esc(c.name)}</code> → <code>${esc(c.fk.table)}${c.fk.col ? '.' + esc(c.fk.col) : ''}</code>`,
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
return `
|
|
242
|
+
<div class="er-canvas">
|
|
243
|
+
${entities.tables.map(tableBox).join('')}
|
|
244
|
+
${entities.buckets.map(bucketBox).join('')}
|
|
245
|
+
</div>
|
|
246
|
+
${rels.length ? `<div class="er-rels"><span class="muted">Relationships:</span> ${rels.join(' · ')}</div>` : ''}`;
|
|
247
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component composition — browser-window pages + nested-rectangle containment.
|
|
3
|
+
*
|
|
4
|
+
* Three coordinated views from collectComposition():
|
|
5
|
+
* 1. Page flow — a compact layered graph of the app's pages (mini browser
|
|
6
|
+
* windows keyed by URL) with arrows for the content navigation between them
|
|
7
|
+
* (drawn client-side by COMPOSITION_FLOW_SCRIPT from the injected edge data).
|
|
8
|
+
* 2. Page composition — every page as a real browser window (chrome + address
|
|
9
|
+
* bar) whose feature components are *physically* nested inside it, box-in-box.
|
|
10
|
+
* 3. Unattached — feature components no page reaches.
|
|
11
|
+
*
|
|
12
|
+
* The name + one-line description show always; props / state / events / public API
|
|
13
|
+
* live inside a <details> the visitor expands. Design-system widgets from
|
|
14
|
+
* src/common are not shown — see the collector header.
|
|
15
|
+
*/
|
|
16
|
+
import { esc, num } from '../utils/format.mjs';
|
|
17
|
+
|
|
18
|
+
const ROLE_LABEL = {
|
|
19
|
+
page: 'page',
|
|
20
|
+
layout: 'layout',
|
|
21
|
+
app: 'app',
|
|
22
|
+
public: 'public',
|
|
23
|
+
internal: 'internal',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const trunc = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
|
27
|
+
|
|
28
|
+
const locLabel = (d) =>
|
|
29
|
+
d.layer === 'app' ? d.rel.replace(/^src\//, '') : `features/${d.feature} · ${d.file}`;
|
|
30
|
+
|
|
31
|
+
const LOCK = `<svg class="comp-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>`;
|
|
32
|
+
|
|
33
|
+
function metaHtml(d) {
|
|
34
|
+
const props = d.props.length
|
|
35
|
+
? d.props
|
|
36
|
+
.map(
|
|
37
|
+
(p) =>
|
|
38
|
+
`<code>${esc(p.name)}</code>${p.type ? `<span class="comp-ty">: ${esc(trunc(p.type, 44))}</span>` : ''}`,
|
|
39
|
+
)
|
|
40
|
+
.join('<span class="comp-sep">,</span> ')
|
|
41
|
+
: '<span class="muted">none</span>';
|
|
42
|
+
|
|
43
|
+
const state = d.state.length
|
|
44
|
+
? d.state
|
|
45
|
+
.map((s) => `<code>${esc(s.name)}</code><span class="comp-hk">${esc(s.hook)}</span>`)
|
|
46
|
+
.join(' ')
|
|
47
|
+
: '<span class="muted">stateless</span>';
|
|
48
|
+
|
|
49
|
+
const evParts = [
|
|
50
|
+
...d.events.callbacks.map((c) => `<code>${esc(c)}</code>`),
|
|
51
|
+
...d.events.tracked.map((t) => `<span class="comp-ev">${esc(t)}</span>`),
|
|
52
|
+
];
|
|
53
|
+
const events = evParts.length ? evParts.join(' ') : '<span class="muted">none</span>';
|
|
54
|
+
|
|
55
|
+
let api;
|
|
56
|
+
if (d.layer === 'app') {
|
|
57
|
+
api = '<span class="muted">route · default export</span>';
|
|
58
|
+
} else {
|
|
59
|
+
const tag = d.public
|
|
60
|
+
? '<span class="comp-ev">public · index.ts</span>'
|
|
61
|
+
: '<span class="muted">internal</span>';
|
|
62
|
+
const ex = d.exports.filter((e) => e !== 'default' && e !== d.name);
|
|
63
|
+
api = `${tag}${ex.length ? ` <span class="comp-ty">+ ${ex.map(esc).join(', ')}</span>` : ''}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const row = (k, v) =>
|
|
67
|
+
`<div class="comp-mrow"><span class="comp-mk">${k}</span><span class="comp-mv">${v}</span></div>`;
|
|
68
|
+
return `<div class="comp-meta">${row('Props', props)}${row('State', state)}${row('Events', events)}${row('Public API', api)}</div>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compact page-flow graph: only the pages that take part in a transition are laid
|
|
73
|
+
* out as layered mini browser windows (BFS distance from `/`); pages no content
|
|
74
|
+
* link reaches are listed compactly below instead of stretching an empty row.
|
|
75
|
+
* Render order == DOM order of `.cflow-node` == the edge indices in the JSON data.
|
|
76
|
+
*/
|
|
77
|
+
function flowHtml(t, host) {
|
|
78
|
+
if (!t || !t.edges.length) {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
const graphNodes = t.nodes.filter((n) => n.inCount + n.outCount > 0);
|
|
82
|
+
const orphans = t.nodes.filter((n) => n.inCount + n.outCount === 0);
|
|
83
|
+
const idToIdx = new Map(graphNodes.map((n, i) => [n.id, i]));
|
|
84
|
+
const layers = [];
|
|
85
|
+
graphNodes.forEach((n) => {
|
|
86
|
+
(layers[n.layer] ||= []).push(n);
|
|
87
|
+
});
|
|
88
|
+
const url = (route) => `${esc(host)}${esc(route === '/' ? '/' : route)}`;
|
|
89
|
+
const node = (n, i) =>
|
|
90
|
+
`<div class="cflow-node" data-idx="${i}">
|
|
91
|
+
<div class="cflow-chrome"><span class="cflow-dots"><i></i><i></i><i></i></span><span class="cflow-url">${url(n.route)}</span></div>
|
|
92
|
+
<div class="cflow-meta"><span class="cflow-comp">${esc(n.component)}</span><span class="cflow-deg">in ${n.inCount} · out ${n.outCount}</span></div>
|
|
93
|
+
</div>`;
|
|
94
|
+
const rows = layers
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map(
|
|
97
|
+
(row) =>
|
|
98
|
+
`<div class="cflow-row">${row.map((n) => node(n, idToIdx.get(n.id))).join('')}</div>`,
|
|
99
|
+
)
|
|
100
|
+
.join('');
|
|
101
|
+
const pairs = t.edges
|
|
102
|
+
.map((e) => [idToIdx.get(e.from), idToIdx.get(e.to)])
|
|
103
|
+
.filter((p) => p[0] != null && p[1] != null);
|
|
104
|
+
const data = JSON.stringify(pairs).replace(/</g, '\\u003c');
|
|
105
|
+
const svg = `<svg class="cflow-svg" id="cflow-svg" xmlns="http://www.w3.org/2000/svg"><defs><marker id="cflow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#3f6fb0"/></marker></defs></svg>`;
|
|
106
|
+
const orphanHtml = orphans.length
|
|
107
|
+
? `<div class="cflow-orphans"><span class="muted">${orphans.length} page(s) with no content links (reached via global nav only):</span> ${orphans.map((n) => `<span class="cflow-orphan">${url(n.route)}</span>`).join('')}</div>`
|
|
108
|
+
: '';
|
|
109
|
+
return `<h3 class="comp-subh">Page flow <span class="muted">— ${t.edges.length} content transition(s)</span></h3>
|
|
110
|
+
<div class="cflow-wrap"><div class="cflow" id="cflow">${svg}<div class="cflow-layers">${rows}</div></div></div>
|
|
111
|
+
${orphanHtml}
|
|
112
|
+
<script id="comp-flow-data" type="application/json">${data}</script>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function compositionGraphHtml(comp) {
|
|
116
|
+
if (!comp || !comp.available) {
|
|
117
|
+
return `<p class="muted">No app routes or feature components found.</p>`;
|
|
118
|
+
}
|
|
119
|
+
const C = comp.components;
|
|
120
|
+
const host = comp.host || '';
|
|
121
|
+
|
|
122
|
+
const linksHtml = (d) =>
|
|
123
|
+
d.links && d.links.length
|
|
124
|
+
? `<span class="comp-links">${d.links.map((l) => `<span class="comp-link">${esc(l)}</span>`).join('')}</span>`
|
|
125
|
+
: '';
|
|
126
|
+
|
|
127
|
+
const cardHtml = (d) => `<details class="comp-card">
|
|
128
|
+
<summary class="comp-sum">
|
|
129
|
+
<span class="comp-name">${esc(d.component || d.name)}</span>
|
|
130
|
+
<span class="comp-badge role-${esc(d.role)}">${ROLE_LABEL[d.role] || d.role}</span>
|
|
131
|
+
<span class="comp-loc">${esc(locLabel(d))}</span>
|
|
132
|
+
${d.description ? `<span class="comp-desc">${esc(d.description)}</span>` : ''}
|
|
133
|
+
${linksHtml(d)}
|
|
134
|
+
</summary>
|
|
135
|
+
${metaHtml(d)}
|
|
136
|
+
</details>`;
|
|
137
|
+
|
|
138
|
+
// A page root → a browser window; its rendered components nest inside the body.
|
|
139
|
+
const winBox = (d, nest) => {
|
|
140
|
+
const url = `${host}${d.name === '/' ? '/' : d.name}`;
|
|
141
|
+
return `<div class="comp-box role-page comp-win" data-rid="${esc(d.id)}">
|
|
142
|
+
<div class="comp-winbar">
|
|
143
|
+
<span class="comp-dots"><i></i><i></i><i></i></span>
|
|
144
|
+
<span class="comp-addr">${LOCK}<span class="comp-addr-url">${esc(url)}</span></span>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="comp-winbody">${cardHtml(d)}${nest}</div>
|
|
147
|
+
</div>`;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const box = (node) => {
|
|
151
|
+
const d = C[node.id];
|
|
152
|
+
if (!d) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
if (node.cycle) {
|
|
156
|
+
return `<div class="comp-box role-${esc(d.role)} is-cycle"><div class="comp-cyc"><span class="comp-name">${esc(d.component || d.name)}</span><span class="comp-tag">↺ shown above</span></div></div>`;
|
|
157
|
+
}
|
|
158
|
+
const kids = (node.children || []).map(box).join('');
|
|
159
|
+
const nest = kids ? `<div class="comp-nest">${kids}</div>` : '';
|
|
160
|
+
if (d.kind === 'page') {
|
|
161
|
+
return winBox(d, nest);
|
|
162
|
+
}
|
|
163
|
+
return `<div class="comp-box role-${esc(d.role)}">${cardHtml(d)}${nest}</div>`;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const s = comp.stats;
|
|
167
|
+
const summary = `<p class="muted">${s.pages} page(s) shown as browser windows, each physically nesting the feature components it renders (box-in-box) across ${s.features.length} feature(s) · ${num(s.boxes)} box(es). Arrows in <strong>Page flow</strong> trace content navigation between pages. Click a box for its props, state, events & public API. Design-system widgets from <code>src/common</code> are omitted.</p>`;
|
|
168
|
+
|
|
169
|
+
const legend = `<div class="comp-legend">
|
|
170
|
+
<span class="sw"><i class="role-page"></i>page · browser window</span>
|
|
171
|
+
<span class="sw"><i class="role-layout"></i>layout</span>
|
|
172
|
+
<span class="sw"><i class="role-public"></i>feature public API</span>
|
|
173
|
+
<span class="sw"><i class="role-internal"></i>feature internal</span>
|
|
174
|
+
<span class="sw"><b class="comp-leg-arrow">→</b> page transition</span>
|
|
175
|
+
</div>`;
|
|
176
|
+
|
|
177
|
+
const controls = `<div class="comp-controls">
|
|
178
|
+
<button type="button" data-comp-toggle="open">Expand all</button>
|
|
179
|
+
<button type="button" data-comp-toggle="close">Collapse all</button>
|
|
180
|
+
</div>`;
|
|
181
|
+
|
|
182
|
+
const flow = flowHtml(comp.transitions, host);
|
|
183
|
+
|
|
184
|
+
const tree = `<h3 class="comp-subh">Page composition</h3><div class="comp-scroll"><div class="comp-tree">${comp.roots.map(box).join('')}</div></div>`;
|
|
185
|
+
|
|
186
|
+
const unattached = comp.unattached.length
|
|
187
|
+
? `<details class="comp-unattached"><summary>${comp.unattached.length} feature component(s) not reached from any page</summary>
|
|
188
|
+
<div class="comp-scroll"><div class="comp-tree">${comp.unattached.map(box).join('')}</div></div></details>`
|
|
189
|
+
: '';
|
|
190
|
+
|
|
191
|
+
return `${summary}${legend}${flow}${controls}${tree}${unattached}`;
|
|
192
|
+
}
|