diff --git a/card/README.md b/card/README.md
index 73c4f18a..58c19b2f 100644
--- a/card/README.md
+++ b/card/README.md
@@ -1,8 +1,8 @@
# CodeFlow Card
-A GitHub Action that drops a slick auto-updating SVG card on your README — health grade, scale, fragility, hidden costs — recomputed every merge by [codeflow](https://github.com/braedonsaunders/codeflow).
+A GitHub Action that drops a slick auto-updating SVG card on your README ظ¤ health grade, scale, fragility, hidden costs ظ¤ recomputed every merge by [codeflow](https://github.com/braedonsaunders/codeflow).
-The card uses the **same analyzer** as the codeflow web app. There's no separate parser, no version drift — the Action reads codeflow's `index.html` and runs its analyzer in a Node `vm`.
+The card uses the **same analyzer** as the codeflow web app. There's no separate parser, no version drift ظ¤ the Action reads codeflow's `index.html` and runs its analyzer in a Node `vm`.
## Headless analysis
@@ -74,10 +74,10 @@ The Action commits the rendered SVG to `.github/codeflow-card.svg` (overwriting
## What's on the card
-- **Health** — letter grade (A+ → F) with delta arrow vs the last run, plus the underlying score.
-- **Scale** — files / functions / LOC / languages, each with a 30-run sparkline (after the second run).
-- **Fragility** — top 3 highest-blast-radius files. The numbers nobody usually shows.
-- **Hidden costs** — circular deps, dead code %, average coupling. Lower-is-better arrows.
+- **Health** ظ¤ letter grade (A+ ظْ F) with delta arrow vs the last run, plus the underlying score.
+- **Scale** ظ¤ files / functions / LOC / languages, each with a 30-run sparkline (after the second run).
+- **Fragility** ظ¤ top 3 highest-blast-radius files. The numbers nobody usually shows.
+- **Hidden costs** ظ¤ circular deps, dead code %, average coupling. Lower-is-better arrows.
## PR receipts (opt-in)
@@ -89,10 +89,10 @@ PR #482 @yourhandle
--------------------------
LOC +312
functions +4
-dead code −1
-circular deps −1
-blast radius 23 → 18 ▼
-health B+ → A- ▲
+dead code ظêْ1
+circular deps ظêْ1
+blast radius 23 ظْ 18 ظû╝
+health B+ ظْ A- ظû▓
--------------------------
thank you for your merge
```
@@ -101,7 +101,7 @@ The comment is sticky (updates in place via `` mar
## Notes
-- **First run**: with no history yet, sparklines and deltas don't render — the panels degrade gracefully.
+- **First run**: with no history yet, sparklines and deltas don't render ظ¤ the panels degrade gracefully.
- **Permissions**: the workflow needs `contents: write` to commit the SVG, and `pull-requests: write` if `receipts: true`.
-- **CI cost**: analysis runs in pure Node (no Docker, no external APIs); typical run is 10–30 seconds depending on repo size.
+- **CI cost**: analysis runs in pure Node (no Docker, no external APIs); typical run is 10ظô30 seconds depending on repo size.
- **Privacy**: nothing leaves the runner. Same guarantee as the codeflow web app.
diff --git a/card/action.yml b/card/action.yml
index 99ff651f..8c2c2cd5 100644
--- a/card/action.yml
+++ b/card/action.yml
@@ -30,7 +30,7 @@ inputs:
required: false
default: 'compact'
panels:
- description: 'Only used when style=detailed. Comma-separated: grade, scale, languages, fragility, hidden-costs, patterns.'
+ description: 'Only used when style=detailed. Comma-separated: grade, scale, languages, composition, top-folders, fragility, hidden-costs. Empty string shows all of those panels.'
required: false
default: 'grade,scale'
show-grade:
diff --git a/card/index.js b/card/index.js
index 11a3c0bf..19c45581 100644
--- a/card/index.js
+++ b/card/index.js
@@ -1,4 +1,4 @@
-// CodeFlow Card — GitHub Action entry point.
+// CodeFlow Card ظ¤ GitHub Action entry point.
// Reads codeflow's analyzer out of index.html, runs it on the consuming repo,
// renders an SVG card, optionally posts a PR receipt comment, then commits the
// updated card + history file back to the repo.
@@ -83,12 +83,12 @@ async function run() {
const outAbs = path.resolve(repoRoot, inputs.output);
fs.mkdirSync(path.dirname(outAbs), { recursive: true });
fs.writeFileSync(outAbs, svg);
- log('wrote card → ' + path.relative(repoRoot, outAbs));
+ log('wrote card ظْ ' + path.relative(repoRoot, outAbs));
// Append snapshot to state.
const updatedState = appendRun(state, snapshot, inputs.sparklineWindow);
writeState(stateAbs, updatedState);
- log('wrote state → ' + path.relative(repoRoot, stateAbs));
+ log('wrote state ظْ ' + path.relative(repoRoot, stateAbs));
// Receipt comment (opt-in, only on merged PRs).
const isMergedPR =
@@ -117,7 +117,7 @@ async function run() {
log('receipt post failed: ' + (e.message || e));
}
} else if (inputs.receipts) {
- log('receipts enabled, but not a merged PR — skipping');
+ log('receipts enabled, but not a merged PR ظ¤ skipping');
}
// Commit changes back to the repo (skip when running outside a checkout).
@@ -136,7 +136,7 @@ async function run() {
log('git commit failed: ' + (e.message || e));
}
} else {
- log('local mode — skipping git commit');
+ log('local mode ظ¤ skipping git commit');
}
}
diff --git a/card/lib/analyzer.js b/card/lib/analyzer.js
index 3f65f004..18fce52b 100644
--- a/card/lib/analyzer.js
+++ b/card/lib/analyzer.js
@@ -1,5 +1,5 @@
// Extract the codeflow analyzer block from index.html and run it in a Node vm
-// context. Mirrors what tests/codeflow-golden.test.mjs does — the analyzer is
+// context. Mirrors what tests/codeflow-golden.test.mjs does ظ¤ the analyzer is
// the single source of truth, lives in one file, never drifts.
'use strict';
diff --git a/card/lib/grade.js b/card/lib/grade.js
new file mode 100644
index 00000000..74f57b9b
--- /dev/null
+++ b/card/lib/grade.js
@@ -0,0 +1,30 @@
+'use strict';
+
+// Shared grade ranking for SVG cards and PR receipts.
+// Order: F < D- < D < D+ < C- < C < C+ < B- < B < B+ < A- < A < A+
+
+const LETTER_RANK = { F: 0, D: 1, C: 2, B: 3, A: 4 };
+
+function gradeRank(grade) {
+ if (!grade || typeof grade !== 'string') return -1;
+ const letter = grade[0].toUpperCase();
+ const base = LETTER_RANK[letter];
+ if (base == null) return -1;
+ let modifier = 0;
+ if (grade.length > 1) {
+ if (grade[1] === '+') modifier = 0.3;
+ else if (grade[1] === '-') modifier = -0.3;
+ }
+ return base + modifier;
+}
+
+function compareGrades(curr, prev) {
+ const ci = gradeRank(curr);
+ const pi = gradeRank(prev);
+ if (ci < 0 || pi < 0) return 0;
+ if (ci > pi) return 1;
+ if (ci < pi) return -1;
+ return 0;
+}
+
+module.exports = { gradeRank, compareGrades };
diff --git a/card/render/receipt-md.js b/card/render/receipt-md.js
index 42a5f9d5..e6b98dd2 100644
--- a/card/render/receipt-md.js
+++ b/card/render/receipt-md.js
@@ -3,6 +3,8 @@
'use strict';
+const { compareGrades } = require('../lib/grade.js');
+
function diff(curr, prev) {
if (prev == null || curr == null) return null;
if (typeof curr !== 'number' || typeof prev !== 'number') return null;
@@ -25,11 +27,9 @@ function dirArrow(n, lowerIsBetter) {
function gradeArrow(curr, prev) {
if (!curr || !prev || curr === prev) return '';
- const order = ['F', 'D', 'C', 'B', 'A'];
- const c = order.indexOf(curr[0]);
- const p = order.indexOf(prev[0]);
- if (c > p) return ' ▲';
- if (c < p) return ' ▼';
+ const cmp = compareGrades(curr, prev);
+ if (cmp > 0) return ' ▲';
+ if (cmp < 0) return ' ▼';
return '';
}
diff --git a/card/render/styles.js b/card/render/styles.js
index 3602b9e5..e4ba316c 100644
--- a/card/render/styles.js
+++ b/card/render/styles.js
@@ -5,6 +5,7 @@
const { getTheme } = require('./theme.js');
const { sparkline } = require('./sparkline.js');
+const { compareGrades } = require('../lib/grade.js');
const ALL_STYLES = ['compact', 'row', 'minimal', 'hero', 'detailed'];
@@ -36,12 +37,9 @@ function gradeColor(theme, grade) {
function gradeArrow(curr, prev, theme) {
if (!prev || !curr || curr === prev) return '';
- const order = ['F', 'D', 'C', 'B', 'A'];
- const ci = order.indexOf(curr[0]);
- const pi = order.indexOf(prev[0]);
- if (ci < 0 || pi < 0) return '';
- if (ci > pi) return '▲';
- if (ci < pi) return '▼';
+ const cmp = compareGrades(curr, prev);
+ if (cmp > 0) return '▲';
+ if (cmp < 0) return '▼';
return '';
}
diff --git a/index.html b/index.html
index 685506fb..064b3f7d 100644
--- a/index.html
+++ b/index.html
@@ -302,6 +302,24 @@
.lang-item{display:flex;align-items:center;gap:4px;font-size:9px;color:var(--t2)}
.lang-dot{width:8px;height:8px;border-radius:2px}
.loc-stat{text-align:center;padding:8px;background:var(--bg0);border:1px solid var(--border2);border-radius:6px;margin-top:8px}
+.overview-section{margin-top:12px;padding:10px;background:var(--bg0);border:1px solid var(--border2);border-radius:6px}
+.overview-section-title{font-size:9px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:8px}
+.health-factor{display:grid;grid-template-columns:1fr auto;gap:2px 8px;align-items:center;margin-bottom:7px}
+.health-factor:last-child{margin-bottom:0}
+.health-factor-label{font-size:10px;color:var(--t1);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.health-factor-meta{font-size:9px;color:var(--t3);font-variant-numeric:tabular-nums}
+.health-factor-bar{grid-column:1/-1;height:4px;background:var(--bg3);border-radius:2px;overflow:hidden}
+.health-factor-fill{height:100%;border-radius:2px;background:var(--acc);opacity:0.85}
+.health-factor-fill.warn{background:var(--orange)}
+.health-factor-fill.bad{background:var(--red)}
+.fragile-row{display:flex;align-items:center;gap:8px;padding:6px 4px;border-radius:4px;cursor:pointer}
+.fragile-row:hover{background:var(--hover)}
+.fragile-rank{width:16px;height:16px;border-radius:4px;background:var(--bg3);color:var(--t3);font-size:9px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
+.fragile-info{flex:1;min-width:0}
+.fragile-name{font-size:10px;color:var(--t0);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.fragile-meta{font-size:8px;color:var(--t3);margin-top:1px}
+.fragile-score{font-size:10px;font-weight:700;color:var(--acc);flex-shrink:0;font-variant-numeric:tabular-nums}
+.fragile-empty{font-size:10px;color:var(--t3);padding:4px 0}
.loc-value{font-size:20px;font-weight:700;color:var(--acc)}
.loc-label{font-size:9px;color:var(--t3);text-transform:uppercase}
.unused-fn{background:var(--bg0);border:1px solid var(--border);border-radius:8px;margin-bottom:8px;overflow:hidden}
@@ -394,6 +412,29 @@
.architecture-list{display:flex;flex-direction:column;gap:6px}
.architecture-list-item{display:flex;align-items:center;gap:8px;font-size:10px;color:var(--t2);min-width:0}
.architecture-list-item span:last-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.arch-violation{padding:8px;margin-bottom:6px;border-radius:6px;background:rgba(255,95,95,0.06);border:1px solid rgba(255,95,95,0.22);cursor:pointer}
+.arch-violation:hover{border-color:var(--red);background:rgba(255,95,95,0.1)}
+.arch-violation:last-child{margin-bottom:0}
+.arch-violation-edge{font-size:10px;font-weight:600;color:var(--t0);display:flex;align-items:center;gap:6px;min-width:0;flex-wrap:wrap}
+.arch-violation-file{color:var(--acc);cursor:pointer}
+.arch-violation-file:hover{text-decoration:underline}
+.arch-violation-layers{font-size:9px;color:var(--red);margin-top:3px}
+.arch-violation-hint{font-size:9px;color:var(--t3);margin-top:4px;line-height:1.4}
+.arch-violation-empty{font-size:10px;color:var(--t3);padding:4px 0}
+.architecture-list-item.selected{background:var(--accbg);color:var(--acc);border-radius:4px;padding:4px 6px;margin:0 -6px}
+.architecture-list-item{cursor:pointer}
+.architecture-list-item:hover{color:var(--t0)}
+.arch-inspect-files{margin-top:8px;display:flex;flex-direction:column;gap:4px}
+.arch-inspect-file{font-size:9px;color:var(--acc);font-family:monospace;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.arch-inspect-file:hover{text-decoration:underline}
+.arch-rules-row{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}
+.arch-rules-row label{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:0.4px}
+.arch-rules-row select{flex:1;min-width:140px;font-size:10px;font-family:inherit;background:var(--bg0);color:var(--t0);border:1px solid var(--border);border-radius:6px;padding:4px 8px}
+.mermaid-render g.node{cursor:pointer}
+.mermaid-render g.node.arch-node-selected rect,
+.mermaid-render g.node.arch-node-selected polygon,
+.mermaid-render g.node.arch-node-selected circle,
+.mermaid-render g.node.arch-node-selected path{stroke:var(--acc)!important;stroke-width:3px!important;filter:drop-shadow(0 0 4px rgba(0,255,157,0.35))}
.pr-impact-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.pr-impact-card{background:var(--bg0);border:1px solid var(--border);border-radius:10px;padding:14px;overflow:hidden}
.pr-impact-card-title{font-size:10px;font-weight:600;color:var(--t3);text-transform:uppercase;margin-bottom:12px;display:flex;align-items:center;gap:6px}
@@ -1627,9 +1668,13 @@
// Create fingerprint string
return 'L'+patterns.loops+'C'+patterns.conditions+'F'+patterns.calls+'R'+patterns.returns+'S'+patterns.len;
},
- detectLayerViolations:function(files,connections){
+ detectLayerViolations:function(files,connections,options){
+ options=options||{};
+ var preset=getArchitectureLayerRulePreset(options.preset||options.rules||'classic');
+ if(preset.disabled)return[];
var violations=[];
- var layerOrder={presentation:0,ui:0,component:0,components:0,page:0,view:0,feature:1,service:2,services:2,api:2,data:3,model:3,util:4,utils:4,helper:4,lib:4,core:4,config:5,test:6,modules:5,forms:0,classes:3};
+ var layerOrder=options.layerOrder||preset.layerOrder;
+ var minGap=options.minGap!=null?options.minGap:preset.minGap;
var fileByPath={};
files.forEach(function(f){fileByPath[f.path]=f;});
connections.forEach(function(c){
@@ -1647,7 +1692,8 @@
var importedLevel=layerOrder[importedLayer];
var importerLevel=layerOrder[importerLayer];
// Violation: a more-foundational file (higher level number) imports from a higher-up layer.
- if(importerLevel!==undefined&&importedLevel!==undefined&&importerLevel>importedLevel&&importerLevel-importedLevel>1){
+ // classic minGap=1 (skip adjacent); strict minGap=0 (any upward).
+ if(importerLevel!==undefined&&importedLevel!==undefined&&importerLevel>importedLevel&&importerLevel-importedLevel>minGap){
violations.push({
from:importerFile.path,
fromLayer:importerFile.layer,
@@ -3764,6 +3810,24 @@
var ARCHITECTURE_MAX_BLOCKS=64;
var ARCHITECTURE_MAX_RENDERED_DEPENDENCIES=48;
+var ARCHITECTURE_LAYER_ORDER_DEFAULT={presentation:0,ui:0,component:0,components:0,page:0,view:0,feature:1,service:2,services:2,api:2,data:3,model:3,util:4,utils:4,helper:4,lib:4,core:4,config:5,test:6,modules:5,forms:0,classes:3};
+
+function normalizeArchitectureLayerRule(value){
+ var v=String(value==null?'classic':value).toLowerCase();
+ if(v==='strict'||v==='off'||v==='classic')return v;
+ return'classic';
+}
+
+function getArchitectureLayerRulePreset(id){
+ var key=normalizeArchitectureLayerRule(id);
+ if(key==='off')return{id:'off',label:'Off',disabled:true,minGap:1,layerOrder:ARCHITECTURE_LAYER_ORDER_DEFAULT};
+ if(key==='strict')return{id:'strict',label:'Strict (any upward)',disabled:false,minGap:0,layerOrder:ARCHITECTURE_LAYER_ORDER_DEFAULT};
+ return{id:'classic',label:'Classic (skip adjacent)',disabled:false,minGap:1,layerOrder:ARCHITECTURE_LAYER_ORDER_DEFAULT};
+}
+
+function listArchitectureLayerRulePresets(){
+ return['classic','strict','off'].map(getArchitectureLayerRulePreset);
+}
var ARCHITECTURE_GROUP_ORDER_CODEFLOW=['Browser App','GitHub Action','Analysis Core','Repository Collection','Rendering / Reports','Testing','Fixtures / Examples','Application','Storage'];
var ARCHITECTURE_GROUP_ORDER_WEBAPP=['App Entry / Shell','Frontend Routes / Views','Frontend Components','Backend / API Layer','Services / Business Logic','Data / Storage','Shared / Utilities','Configuration','Content / Data','External Integrations','Build Output','Testing','Fixtures / Examples'];
var ARCHITECTURE_GROUP_ORDER_GENERIC=['Application','Shared Services / Utils','Configuration','Content / Data','Build Output','Testing','Fixtures / Examples','Storage'];
@@ -4425,7 +4489,7 @@
route:classified.route,
displayTitle:displayTitle,
role:role,
- group:inferArchitectureGroup(role,{kind:classified.kind},profile),
+ group:inferArchitectureGroup(role,{kind:classified.kind,path:path},profile),
profile:profile,
isTest:isArchitectureTestFile(path),
isFixture:isArchitectureFixtureFile(path),
@@ -5042,6 +5106,103 @@
});
}
+function summarizeArchitectureViolations(violations,options){
+ options=options||{};
+ var max=options.max==null?12:options.max;
+ var list=Array.isArray(violations)?violations:[];
+ var seen=Object.create(null);
+ var unique=[];
+ for(var i=0;i-
+ var m=String(rawId).match(/^flowchart-(.+?)-\d+$/);
+ if(m&&idSet[m[1]])return m[1];
+ for(var i=0;i0?(data.stats.dead/data.stats.functions*100):0;
- score-=Math.min(20,deadPct);
- var circular=data.issues.filter(function(i){return i.title.includes('Circular');}).length;
- score-=Math.min(20,circular*5);
- var god=data.issues.filter(function(i){return i.title.includes('Large');}).length;
- score-=Math.min(15,god*3);
+ var deadPenalty=Math.min(20,deadPct);
+ score-=deadPenalty;
+ // Score still uses issue-object presence (historic formula). Breakdown details use item counts.
+ var circularIssues=(data.issues||[]).filter(function(i){return i.title&&i.title.includes('Circular');});
+ var circularPenalty=Math.min(20,circularIssues.length*5);
+ score-=circularPenalty;
+ var godIssues=(data.issues||[]).filter(function(i){return i.title&&i.title.includes('Large');});
+ var godPenalty=Math.min(15,godIssues.length*3);
+ score-=godPenalty;
var avgCoup=data.stats.files>0?(data.stats.connections/data.stats.files):0;
- score-=Math.min(15,Math.max(0,avgCoup-3)*2);
+ var coupPenalty=Math.min(15,Math.max(0,avgCoup-3)*2);
+ score-=coupPenalty;
var sec=data.securityIssues?data.securityIssues.filter(function(i){return i.severity==='high';}).length:0;
- score-=Math.min(20,sec*5);
+ var secPenalty=Math.min(20,sec*5);
+ score-=secPenalty;
score=Math.max(0,Math.round(score));
var grade='F';
if(score>=90)grade='A';else if(score>=80)grade='B';else if(score>=70)grade='C';else if(score>=60)grade='D';
- return{score:score,grade:grade};
+ function issueItemCount(issues){
+ var total=0;
+ for(var i=0;i18?n.slice(0,17)+'…':n;var maxLen=Math.max(4,Math.floor(getR(d)/2));return n.length>maxLen+1?n.slice(0,maxLen)+'…':n;});
+ node.append('text').attr('class','node-label').attr('text-anchor','middle').attr('dominant-baseline','central').attr('dy','0.05em').attr('fill',theme==='light'?'#333':'#eee').attr('font-size',function(d){return (keepReadable?Math.max(10,Math.min(13,getR(d)*0.55)):Math.max(9,Math.min(12,getR(d)*0.48)))+'px';}).attr('font-family','JetBrains Mono').attr('font-weight','600').attr('pointer-events','none').text(function(d){var n=d.name.replace(/\.[^.]+$/,'');if(keepReadable)return n.length>18?n.slice(0,17)+'…':n;var maxLen=Math.max(6,Math.floor(getR(d)/2.4));return n.length>maxLen+1?n.slice(0,maxLen)+'…':n;});
}
function liveNodesByFolder(){
var live=(simRef.current&&simRef.current.nodes())||nodes;
@@ -12461,7 +12703,7 @@
}
function getR(d){
- var base=Math.max(6,Math.min(20,4+d.fnCount*0.4));
+ var base=Math.max(10,Math.min(26,8+d.fnCount*0.55));
if(selected){
if(d.id===selected.path)return base*2.0;
if(blastRadius&&blastRadius.affected.indexOf(d.id)>=0)return base*1.4;
@@ -13411,7 +13653,9 @@
startX:e.clientX,
startY:e.clientY,
originX:architectureViewport.x,
- originY:architectureViewport.y
+ originY:architectureViewport.y,
+ moved:false,
+ target:e.target
};
if(e.currentTarget&&e.currentTarget.setPointerCapture){
try{e.currentTarget.setPointerCapture(e.pointerId);}catch(err){}
@@ -13422,16 +13666,31 @@
var drag=architectureDragRef.current;
if(!drag)return;
e.preventDefault();
+ var dx=e.clientX-drag.startX;
+ var dy=e.clientY-drag.startY;
+ if(!drag.moved&&(dx*dx+dy*dy)>16)drag.moved=true;
+ if(!drag.moved)return;
setArchitectureViewport(function(prev){
return Object.assign({},prev,{
- x:drag.originX+e.clientX-drag.startX,
- y:drag.originY+e.clientY-drag.startY
+ x:drag.originX+dx,
+ y:drag.originY+dy
});
});
}
function handleArchitecturePointerUp(e){
- if(e&&e.currentTarget&&e.currentTarget.releasePointerCapture&&architectureDragRef.current){
- try{e.currentTarget.releasePointerCapture(architectureDragRef.current.pointerId);}catch(err){}
+ var drag=architectureDragRef.current;
+ if(e&&e.currentTarget&&e.currentTarget.releasePointerCapture&&drag){
+ try{e.currentTarget.releasePointerCapture(drag.pointerId);}catch(err){}
+ }
+ if(drag&&!drag.moved&&data&&data.architectureDiagram){
+ var blocks=getVisibleArchitectureBlocks(data.architectureDiagram.blocks||[],architectureIncludeTests,architectureIncludeBuildOutput);
+ var ids=blocks.map(function(b){return b.id;});
+ var hit=resolveArchitectureBlockIdFromTarget(drag.target||(e&&e.target),ids);
+ setSelectedArchitectureBlockId(hit||null);
+ if(hit){
+ setRightTab('details');
+ if(isMobile)setMobilePanel('details');
+ }
}
architectureDragRef.current=null;
setArchitectureDragging(false);
@@ -13854,6 +14113,8 @@
}
function renderOverviewPane(){
if(!data)return renderSidebarEmpty('No Repository','Enter a GitHub URL, open a folder, or load a ZIP archive');
+ var breakdown=health.breakdown||[];
+ var fragile=fragileFiles||[];
return React.createElement('div',{className:'sidebar-scroll'},
React.createElement('div',{className:'health-score',style:{marginBottom:10}},
React.createElement(HealthRing,{score:health.score,grade:health.grade}),
@@ -13886,6 +14147,35 @@
React.createElement('span',null,l.ext,' ',l.pct,'%')
);})
)
+ ),
+ breakdown.length>0&&React.createElement('div',{className:'overview-section'},
+ React.createElement('div',{className:'overview-section-title'},'Health breakdown'),
+ breakdown.map(function(factor){
+ var pct=factor.max>0?Math.min(100,(factor.penalty/factor.max)*100):0;
+ var fillClass=pct>=70?' bad':(pct>=40?' warn':'');
+ return React.createElement('div',{key:factor.key,className:'health-factor'},
+ React.createElement('div',{className:'health-factor-label'},factor.label,' · ',factor.detail),
+ React.createElement('div',{className:'health-factor-meta'},factor.penalty>0?'−'+factor.penalty:'0'),
+ React.createElement('div',{className:'health-factor-bar'},
+ React.createElement('div',{className:'health-factor-fill'+fillClass,style:{width:Math.max(pct,factor.penalty>0?6:0)+'%'}})
+ )
+ );
+ })
+ ),
+ React.createElement('div',{className:'overview-section'},
+ React.createElement('div',{className:'overview-section-title'},'Most fragile files'),
+ fragile.length===0
+ ?React.createElement('div',{className:'fragile-empty'},'No high-impact dependency hubs yet')
+ :fragile.map(function(f,i){
+ return React.createElement('div',{key:f.path,className:'fragile-row',title:f.path,onClick:function(){goToFile(f.path);}},
+ React.createElement('div',{className:'fragile-rank'},String(i+1)),
+ React.createElement('div',{className:'fragile-info'},
+ React.createElement('div',{className:'fragile-name'},f.name),
+ React.createElement('div',{className:'fragile-meta'},f.direct,' direct · ',f.transitive,' transitive')
+ ),
+ React.createElement('div',{className:'fragile-score'},f.total)
+ );
+ })
)
);
}
@@ -14324,7 +14614,11 @@
if(!blocks||!blocks.length)return React.createElement('div',{style:{fontSize:10,color:'var(--t3)',padding:8}},emptyText);
return React.createElement('div',{className:'architecture-list'},
blocks.slice(0,12).map(function(block){
- return React.createElement('div',{key:block.id,className:'architecture-list-item'},
+ return React.createElement('div',{
+ key:block.id,
+ className:'architecture-list-item'+(selectedArchitectureBlockId===block.id?' selected':''),
+ onClick:function(){setSelectedArchitectureBlockId(block.id);}
+ },
React.createElement(StatusDot,{color:architectureGroupDotColor(block.group)}),
React.createElement('span',{title:(block.files&&block.files[0])||block.route||block.title},block.title)
);
@@ -14338,14 +14632,22 @@
var stats=getArchitectureViewStats(diagram,architectureIncludeTests,architectureIncludeBuildOutput);
var blocks=getVisibleArchitectureBlocks(diagram.blocks||[],architectureIncludeTests,architectureIncludeBuildOutput);
var hidden=diagram.hiddenSummary||{build:0,tests:0,fixtures:0,lowSignal:0,total:0};
+ var activeViolations=architectureLayerViolations||[];
+ var violationSummary=summarizeArchitectureViolations(activeViolations,{max:12});
+ var selectedBlock=selectedArchitectureBlockId?blocks.find(function(b){return b.id===selectedArchitectureBlockId;})||(diagram.blocks||[]).find(function(b){return b.id===selectedArchitectureBlockId;}):null;
var groupOrder=getArchitectureGroupOrder(diagram.profile||'generic');
+ var rulePresets=listArchitectureLayerRulePresets();
var groupCards=groupOrder.map(function(group){
var groupBlocks=blocks.filter(function(block){return block.group===group;});
if(!groupBlocks.length)return null;
+ var groupViolations=countArchitectureGroupViolations(groupBlocks,activeViolations);
return React.createElement('div',{key:group,className:'card'},
React.createElement('div',{className:'card-header'},
React.createElement('div',{className:'card-title'},group),
- React.createElement('span',{className:'badge badge-default'},groupBlocks.length)
+ React.createElement('div',{style:{display:'flex',gap:6,alignItems:'center'}},
+ groupViolations>0&&React.createElement('span',{className:'badge badge-danger',title:groupViolations+' layer violation'+(groupViolations===1?'':'s')+' involving this group'},groupViolations),
+ React.createElement('span',{className:'badge badge-default'},groupBlocks.length)
+ )
),
React.createElement('div',{className:'card-body'},renderArchitectureBlockList(groupBlocks,'No blocks in this group.'))
);
@@ -14358,6 +14660,64 @@
React.createElement('div',{className:'stat-card'},React.createElement('div',{className:'stat-value'},stats.routes||0),React.createElement('div',{className:'stat-label'},'Routes')),
React.createElement('div',{className:'stat-card warn'},React.createElement('div',{className:'stat-value'},stats.databaseTouchpoints||0),React.createElement('div',{className:'stat-label'},'DB Touches'))
),
+ selectedBlock&&React.createElement('div',{className:'card'},
+ React.createElement('div',{className:'card-header'},
+ React.createElement('div',{className:'card-title'},React.createElement(Icon,{name:'box',size:'s'}),' Selected block'),
+ React.createElement('button',{className:'top-btn',style:{fontSize:9,padding:'2px 8px'},type:'button',onClick:function(){setSelectedArchitectureBlockId(null);}},'Clear')
+ ),
+ React.createElement('div',{className:'card-body'},
+ React.createElement('div',{style:{fontSize:11,fontWeight:600,marginBottom:4}},selectedBlock.title),
+ React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginBottom:6}},selectedBlock.group||'Application',selectedBlock.role?' · '+selectedBlock.role:''),
+ selectedBlock.files&&selectedBlock.files.length
+ ?React.createElement('div',{className:'arch-inspect-files'},
+ selectedBlock.files.slice(0,12).map(function(path){
+ return React.createElement('div',{key:path,className:'arch-inspect-file',title:path,onClick:function(){goToFile(path);}},path);
+ }),
+ selectedBlock.files.length>12&&React.createElement('div',{style:{fontSize:9,color:'var(--t3)'}},'+',selectedBlock.files.length-12,' more files')
+ )
+ :React.createElement('div',{style:{fontSize:10,color:'var(--t3)'}},'No files attached to this block')
+ )
+ ),
+ React.createElement('div',{className:'card'},
+ React.createElement('div',{className:'card-header'},
+ React.createElement('div',{className:'card-title'},React.createElement(Icon,{name:'layers',size:'s'}),' Architecture violations'),
+ React.createElement('span',{className:'badge '+(violationSummary.unique>0?'badge-danger':'badge-default')},violationSummary.unique)
+ ),
+ React.createElement('div',{className:'card-body'},
+ React.createElement('div',{className:'arch-rules-row'},
+ React.createElement('label',{htmlFor:'arch-layer-rules'},'Rules'),
+ React.createElement('select',{
+ id:'arch-layer-rules',
+ value:architectureLayerRules,
+ onChange:function(e){
+ var next=normalizeArchitectureLayerRule(e.target.value);
+ setArchitectureLayerRules(next);
+ persistUiPrefs({architectureLayerRules:next});
+ }
+ },
+ rulePresets.map(function(preset){
+ return React.createElement('option',{key:preset.id,value:preset.id},preset.label);
+ })
+ )
+ ),
+ violationSummary.unique===0
+ ?React.createElement('div',{className:'arch-violation-empty',style:{marginTop:8}},architectureLayerRules==='off'?'Layer rules are off':'No layer violations for this rule preset')
+ :React.createElement(React.Fragment,null,
+ violationSummary.items.map(function(v){
+ return React.createElement('div',{key:v.from+'→'+v.to,className:'arch-violation',onClick:function(){goToFile(v.from);}},
+ React.createElement('div',{className:'arch-violation-edge'},
+ React.createElement('span',{className:'arch-violation-file',title:v.from,onClick:function(e){e.stopPropagation();goToFile(v.from);}},v.fromName),
+ React.createElement('span',null,'→'),
+ React.createElement('span',{className:'arch-violation-file',title:v.to,onClick:function(e){e.stopPropagation();goToFile(v.to);}},v.toName)
+ ),
+ React.createElement('div',{className:'arch-violation-layers'},v.fromLayer,' → ',v.toLayer,v.fns.length?(' · via '+v.fns.slice(0,2).join(', ')):''),
+ v.suggestion&&React.createElement('div',{className:'arch-violation-hint'},v.suggestion)
+ );
+ }),
+ violationSummary.truncated>0&&React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginTop:6}},'+',violationSummary.truncated,' more unique edge',violationSummary.truncated===1?'':'s')
+ )
+ )
+ ),
React.createElement('div',{className:'card'},
React.createElement('div',{className:'card-header'},React.createElement('div',{className:'card-title'},React.createElement(Icon,{name:'globe',size:'s'}),' Framework'),React.createElement('span',{className:'badge badge-info'},diagram.framework)),
React.createElement('div',{className:'card-body'},
@@ -14366,7 +14726,7 @@
React.createElement('button',{className:'top-btn'+(architectureIncludeTests?' primary':''),style:{fontSize:10,padding:'4px 10px'},onClick:function(){setArchitectureIncludeTests(function(v){return !v;});},type:'button'},'Tests'),
React.createElement('button',{className:'top-btn'+(architectureIncludeBuildOutput?' primary':''),style:{fontSize:10,padding:'4px 10px'},onClick:function(){setArchitectureIncludeBuildOutput(function(v){return !v;});},type:'button'},'Build output')
),
- React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginTop:8}},'Pan: drag diagram · Zoom: scroll wheel · Export: navbar Export button')
+ React.createElement('div',{style:{fontSize:9,color:'var(--t3)',marginTop:8}},'Click a block to inspect · Drag to pan · Scroll to zoom')
)
),
hidden.total>0&&React.createElement('div',{className:'card'},
@@ -14386,6 +14746,12 @@
);
}
var health=useMemo(function(){return calcHealth(data);},[data]);
+ var fragileFiles=useMemo(function(){return data?topFragileFiles(data,3):[];},[data]);
+ var architectureLayerViolations=useMemo(function(){
+ if(!data||!data.files||!data.connections)return[];
+ if(typeof Parser==='undefined'||typeof Parser.detectLayerViolations!=='function')return data.layerViolations||[];
+ return Parser.detectLayerViolations(data.files,data.connections,{preset:architectureLayerRules});
+ },[data,architectureLayerRules]);
return React.createElement('div',{className:'app',style:{'--topbar-height':topbarHeight+'px'}},
React.createElement('input',{ref:zipInputRef,type:'file',accept:'.zip,application/zip,application/x-zip-compressed',style:{display:'none'},onChange:handleZipSelected}),
React.createElement('input',{ref:folderInputRef,type:'file',webkitdirectory:'',directory:'',mozdirectory:'',multiple:true,style:{display:'none'},onChange:handleFolderSelected}),
@@ -14965,7 +15331,7 @@
),
showExport&&React.createElement('div',{className:'modal-overlay',onClick:function(){setShowExport(false);}},
React.createElement('div',{className:'modal',onClick:function(e){e.stopPropagation();},style:{maxWidth:480}},
- React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('export','Export','m')),React.createElement('button',{className:'modal-close',onClick:function(){setShowExport(false);}},'×')),
+ React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('export','Export','m')),React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setShowExport(false);}},'×')),
React.createElement('div',{className:'modal-body'},
data&&data.architectureDiagram&&React.createElement(React.Fragment,null,
React.createElement('div',{style:{fontSize:10,fontWeight:600,color:'var(--t3)',textTransform:'uppercase',marginBottom:8}},graphConfig.vizType==='architecture'?'Block Diagram (current view)':'Block Diagram'),
@@ -15004,7 +15370,7 @@
React.createElement('div',{className:'modal-title'},iconLabel('ban','Exclude Patterns','m')),
React.createElement('div',{style:{display:'flex',alignItems:'center',gap:12}},
React.createElement('div',{className:'exclude-count'},parseExcludePatterns(excludePatternDraft).length,' custom'),
- React.createElement('button',{className:'modal-close',onClick:closeExcludeModal},'×')
+ React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:closeExcludeModal},'×')
)
),
React.createElement('div',{className:'modal-body'},
@@ -15037,7 +15403,7 @@
),
showPR&&React.createElement('div',{className:'modal-overlay',onClick:function(){setShowPR(false);}},
React.createElement('div',{className:'modal pr-modal',onClick:function(e){e.stopPropagation();}},
- React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('chart','PR Impact Analyzer','m')),React.createElement('button',{className:'modal-close',onClick:function(){setShowPR(false);}},'×')),
+ React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('chart','PR Impact Analyzer','m')),React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setShowPR(false);}},'×')),
React.createElement('div',{className:'modal-body',style:{maxHeight:'75vh',overflowY:'auto'}},
React.createElement('div',{className:'form-group'},React.createElement('label',{className:'form-label'},'Pull Request URL'),React.createElement('input',{className:'form-input','aria-label':'Pull Request URL',placeholder:'https://github.com/owner/repo/pull/123',value:prUrl,onChange:function(e){setPrUrl(e.target.value);},onKeyDown:function(e){if(e.key==='Enter')analyzePR();}})),
React.createElement('button',{className:'top-btn primary','aria-label':'Analyze Pull Request',onClick:analyzePR,style:{marginBottom:16,width:'100%'}},iconLabel('search','Analyze PR Impact')),
@@ -15173,7 +15539,7 @@
drillDown.type==='duplicate'?iconLabel(drillDown.data.type==='code'?'copy':'note',(drillDown.data.type==='code'?'Similar Code':'Duplicate Name')+': '+drillDown.data.name,'m'):
'Details'
),
- React.createElement('button',{className:'modal-close',onClick:function(){setDrillDown(null);}},'×')
+ React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setDrillDown(null);}},'×')
),
React.createElement('div',{className:'modal-body',style:{overflowY:'auto',flex:1}},
// Issue drill-down
@@ -15296,7 +15662,7 @@
),
showPrivacy&&React.createElement('div',{className:'modal-overlay',onClick:function(){setShowPrivacy(false);}},
React.createElement('div',{className:'modal privacy-modal',onClick:function(e){e.stopPropagation();}},
- React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('lock','Privacy & Security','m')),React.createElement('button',{className:'modal-close',onClick:function(){setShowPrivacy(false);}},'×')),
+ React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('lock','Privacy & Security','m')),React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setShowPrivacy(false);}},'×')),
React.createElement('div',{className:'modal-body'},
React.createElement('div',{className:'privacy-item'},
React.createElement('div',{className:'privacy-icon'},React.createElement(Icon,{name:'globe',size:'l'})),
@@ -15328,7 +15694,7 @@
),
showKeyModal&&React.createElement('div',{className:'modal-overlay',onClick:function(){setShowKeyModal(false);}},
React.createElement('div',{className:'modal key-modal',onClick:function(e){e.stopPropagation();}},
- React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('key','GitHub App Private Key','m')),React.createElement('button',{className:'modal-close',onClick:function(){setShowKeyModal(false);}},'×')),
+ React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('key','GitHub App Private Key','m')),React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setShowKeyModal(false);}},'×')),
React.createElement('div',{className:'modal-body'},
React.createElement('div',{className:'key-info'},
'Paste the private key from your GitHub App. This key is stored only in memory and never leaves your browser.',
@@ -15352,7 +15718,7 @@
),
showUnused&&data&&data.deadFunctions&&React.createElement('div',{className:'modal-overlay',onClick:function(){setShowUnused(false);}},
React.createElement('div',{className:'modal',style:{maxWidth:650,maxHeight:'85vh'},onClick:function(e){e.stopPropagation();}},
- React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('warning','Unused Functions','m')),React.createElement('button',{className:'modal-close',onClick:function(){setShowUnused(false);}},'×')),
+ React.createElement('div',{className:'modal-header'},React.createElement('div',{className:'modal-title'},iconLabel('warning','Unused Functions','m')),React.createElement('button',{className:'modal-close','aria-label':'Close',onClick:function(){setShowUnused(false);}},'×')),
React.createElement('div',{className:'modal-body',style:{maxHeight:'70vh',overflowY:'auto'}},
React.createElement('div',{className:'unused-summary'},
React.createElement('div',{className:'unused-summary-item'},
@@ -15469,7 +15835,7 @@
),
React.createElement('div',{className:'file-preview-actions'},
filePreview.line&&React.createElement('span',{className:'file-preview-line-badge'},'Line ',filePreview.line),
- React.createElement('button',{className:'file-preview-close',onClick:function(){setFilePreview(null);}},'×')
+ React.createElement('button',{className:'file-preview-close','aria-label':'Close',onClick:function(){setFilePreview(null);}},'×')
)
),
React.createElement('div',{className:'file-preview-content',ref:filePreviewRef},
diff --git a/tests/advertised-extensions.test.mjs b/tests/advertised-extensions.test.mjs
index 56addd5b..615f1154 100644
--- a/tests/advertised-extensions.test.mjs
+++ b/tests/advertised-extensions.test.mjs
@@ -3,29 +3,11 @@ import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
import { isWatchableFile } from '../cli/codeflow.mjs';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const html = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const start = html.indexOf('// ===== CODEFLOW_ANALYZER_START =====');
-const end = html.indexOf('// ===== CODEFLOW_ANALYZER_END =====', start);
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) { return file && file.content ? file.content : ''; },
- isSanitizedPreviewRenderer() { return false; },
-};
-
-vm.createContext(context);
-vm.runInContext(
- html.slice(start, end) + '\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;',
- context
-);
-
+const context = await loadAnalyzerContext();
const { Parser } = context;
// README Supported Languages lists these extensions. Until they are in
diff --git a/tests/architecture-diagram.test.mjs b/tests/architecture-diagram.test.mjs
index 5202eb3a..e999fe63 100644
--- a/tests/architecture-diagram.test.mjs
+++ b/tests/architecture-diagram.test.mjs
@@ -3,46 +3,19 @@ import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext, repoRoot } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(
- `${htmlSource.slice(parserStart, parserEnd)}\n` +
- 'this.Parser = Parser;' +
- ' this.buildAnalysisData = buildAnalysisData;' +
- ' this.buildArchitectureDiagram = buildArchitectureDiagram;' +
- ' this.generateMermaidBlockDiagram = generateMermaidBlockDiagram;' +
- ' this.getVisibleArchitectureBlocks = getVisibleArchitectureBlocks;' +
- ' this.getArchitectureGroupOrder = getArchitectureGroupOrder;',
- context
-);
-
-const { Parser, buildAnalysisData, buildArchitectureDiagram, generateMermaidBlockDiagram, getVisibleArchitectureBlocks, getArchitectureGroupOrder } = context;
+const context = await loadAnalyzerContext();
+
+const {
+ Parser,
+ buildAnalysisData,
+ buildArchitectureDiagram,
+ generateMermaidBlockDiagram,
+ getVisibleArchitectureBlocks,
+ getArchitectureGroupOrder,
+} = context;
async function collectRepoFiles(root) {
const files = [];
diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs
index 4b41d6ac..9b799989 100644
--- a/tests/code-canvas.test.mjs
+++ b/tests/code-canvas.test.mjs
@@ -47,6 +47,14 @@ test('color blocks replace fine chrome below the CodeCanvas zoom threshold', ()
assert.ok(context.graphColorBlockSize({ fnCount: 20 }) > context.graphColorBlockSize({ fnCount: 0 }));
});
+test('graph node radius keeps labels readable and scales with functions', () => {
+ assert.ok(context.graphNodeRadius({ fnCount: 0 }) >= 16);
+ assert.ok(context.graphNodeRadius({ fnCount: 20 }) > context.graphNodeRadius({ fnCount: 0 }));
+ assert.ok(context.graphNodeRadius({ fnCount: 100 }) <= 34);
+ assert.match(htmlSource, /dominant-baseline','central'/);
+ assert.match(htmlSource, /function getR\(d\)\{return graphNodeRadius\(d\);\}/);
+});
+
test('zoom-out color blocks avoid red and green diff colors', () => {
['fn', 'import', 'export', 'var', 'file', 'class'].forEach((kind) => {
const fill = context.codeColorBlockKindColor(kind);
@@ -2705,10 +2713,12 @@ test('UI prefs persist line thickness in localStorage', () => {
const storage = memoryStorage();
assert.equal(context.readUiPrefs(storage).lineThickness, 1);
assert.equal(context.readUiPrefs(storage).codeViewRootGate, 25);
+ assert.equal(context.readUiPrefs(storage).architectureLayerRules, 'classic');
assert.equal(context.readUiPrefs(null).lineThickness, 1);
- const written = context.writeUiPrefs(storage, { lineThickness: 5 });
+ const written = context.writeUiPrefs(storage, { lineThickness: 5, architectureLayerRules: 'strict' });
assert.equal(written.lineThickness, 5);
assert.equal(written.codeViewRootGate, 25);
+ assert.equal(written.architectureLayerRules, 'strict');
assert.equal(context.readUiPrefs(storage).lineThickness, 5);
assert.equal(context.writeUiPrefs(storage, { lineThickness: 99 }).lineThickness, 6);
const gated = context.writeUiPrefs(storage, { codeViewRootGate: 75 });
diff --git a/tests/codeflow-golden.test.mjs b/tests/codeflow-golden.test.mjs
index 7ccf536e..d2ac1507 100644
--- a/tests/codeflow-golden.test.mjs
+++ b/tests/codeflow-golden.test.mjs
@@ -3,36 +3,10 @@ import { readdir, readFile } from 'node:fs/promises';
import { basename, dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(`${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;`, context);
-
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
async function collectFixtureFiles(root) {
diff --git a/tests/codeflow-repo-smoke.mjs b/tests/codeflow-repo-smoke.mjs
index c0cef528..f21c69f3 100644
--- a/tests/codeflow-repo-smoke.mjs
+++ b/tests/codeflow-repo-smoke.mjs
@@ -1,34 +1,10 @@
import { readFile, readdir, stat } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
-import vm from 'node:vm';
+import { loadAnalyzerContext, repoRoot } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const parserStart = htmlSource.indexOf('const Parser={');
-const parserEnd = htmlSource.indexOf('\nfunction calcBlast', parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(`${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;`, context);
-
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
const ignoredDirs = new Set([
'.git',
diff --git a/tests/duplicate-function-resolution.test.mjs b/tests/duplicate-function-resolution.test.mjs
index e1d06683..c637c7e2 100644
--- a/tests/duplicate-function-resolution.test.mjs
+++ b/tests/duplicate-function-resolution.test.mjs
@@ -3,36 +3,10 @@ import { readFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(`${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;`, context);
-
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
function makeAnalyzedFile(path, content) {
diff --git a/tests/grade-rank.test.mjs b/tests/grade-rank.test.mjs
new file mode 100644
index 00000000..e72fa613
--- /dev/null
+++ b/tests/grade-rank.test.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+import test from 'node:test';
+
+const require = createRequire(import.meta.url);
+const { gradeRank, compareGrades } = require('../card/lib/grade.js');
+
+test('gradeRank orders plus/minus within the same letter', () => {
+ assert.ok(gradeRank('A+') > gradeRank('A'));
+ assert.ok(gradeRank('A') > gradeRank('A-'));
+ assert.ok(gradeRank('B+') > gradeRank('B-'));
+ assert.ok(gradeRank('A-') > gradeRank('B+'));
+ assert.ok(gradeRank('B') > gradeRank('C+'));
+});
+
+test('compareGrades detects same-letter deltas', () => {
+ assert.equal(compareGrades('B+', 'B-'), 1);
+ assert.equal(compareGrades('B-', 'B+'), -1);
+ assert.equal(compareGrades('A+', 'A-'), 1);
+ assert.equal(compareGrades('A', 'A'), 0);
+ assert.equal(compareGrades('A+', 'B+'), 1);
+ assert.equal(compareGrades('C', 'B'), -1);
+});
+
+test('compareGrades ignores invalid grades', () => {
+ assert.equal(compareGrades('Z', 'A'), 0);
+ assert.equal(compareGrades(null, 'A'), 0);
+});
diff --git a/tests/layer-violation-direction.test.mjs b/tests/layer-violation-direction.test.mjs
index 5c4e554f..8a6bc2f8 100644
--- a/tests/layer-violation-direction.test.mjs
+++ b/tests/layer-violation-direction.test.mjs
@@ -3,35 +3,11 @@ import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext, analyzerPath } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(`${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser;`, context);
+const analyzerSource = await readFile(analyzerPath, 'utf8');
+const context = await loadAnalyzerContext();
const { Parser } = context;
// Convention (index.html): connection {source: fileDefiningFn (imported), target: fileCallingFn (importer)}.
@@ -90,9 +66,9 @@ test('a test file importing a util or service is NOT flagged as a layer violatio
});
test('coupling issue label describes fan-out (files it imports), not fan-in', () => {
- const start = htmlSource.indexOf("title:highCoup.length+' Highly Coupled'");
+ const start = analyzerSource.indexOf("title:highCoup.length+' Highly Coupled'");
assert.ok(start > 0, 'Highly Coupled issue builder not found in analyzer source');
- const snippet = htmlSource.slice(start, start + 200);
+ const snippet = analyzerSource.slice(start, start + 200);
// The stale fan-in wording ("imported by 8+ others") must be gone...
assert.equal(/imported by 8\+ others/.test(snippet), false, 'stale fan-in wording still present');
// ...replaced by wording that describes fan-out (this file imports others).
diff --git a/tests/load-analyzer.mjs b/tests/load-analyzer.mjs
new file mode 100644
index 00000000..10dd8177
--- /dev/null
+++ b/tests/load-analyzer.mjs
@@ -0,0 +1,72 @@
+// Shared helper for tests: load analyzer+metrics blocks from index.html into a vm.
+import { readFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import vm from 'node:vm';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+export const repoRoot = join(__dirname, '..');
+export const htmlPath = join(repoRoot, 'index.html');
+/** @deprecated kept for callers; analyzer now lives in index.html again. */
+export const analyzerPath = htmlPath;
+
+function sliceBlock(html, startMarker, endMarker) {
+ const start = html.indexOf(startMarker);
+ const end = html.indexOf(endMarker, start);
+ if (start < 0 || end < 0) {
+ throw new Error('Could not locate ' + startMarker + ' in index.html');
+ }
+ return html.slice(start, end + endMarker.length);
+}
+
+export async function readAnalyzerSource() {
+ const html = await readFile(htmlPath, 'utf8');
+ const analyzer = sliceBlock(
+ html,
+ '// ===== CODEFLOW_ANALYZER_START =====',
+ '// ===== CODEFLOW_ANALYZER_END ====='
+ );
+ const metrics = sliceBlock(
+ html,
+ '// ===== CODEFLOW_METRICS_START =====',
+ '// ===== CODEFLOW_METRICS_END ====='
+ );
+ return analyzer + '\n' + metrics;
+}
+
+export async function loadAnalyzerContext(expose = {}) {
+ const source = await readAnalyzerSource();
+ const context = {
+ console,
+ TreeSitter: undefined,
+ Babel: undefined,
+ acorn: undefined,
+ getSecurityScanContent(file) {
+ return file && file.content ? file.content : '';
+ },
+ isSanitizedPreviewRenderer() {
+ return false;
+ },
+ ...expose,
+ };
+ vm.createContext(context);
+ const names = [
+ 'Parser',
+ 'buildAnalysisData',
+ 'buildArchitectureDiagram',
+ 'generateMermaidBlockDiagram',
+ 'getVisibleArchitectureBlocks',
+ 'getArchitectureGroupOrder',
+ 'summarizeArchitectureViolations',
+ 'countArchitectureGroupViolations',
+ 'resolveArchitectureBlockIdFromTarget',
+ 'getArchitectureLayerRulePreset',
+ 'normalizeArchitectureLayerRule',
+ 'calcBlast',
+ 'calcHealth',
+ 'topFragileFiles',
+ ];
+ const exposeExports = names.map((name) => `this.${name}=typeof ${name}!=='undefined'?${name}:undefined;`).join('\n');
+ vm.runInContext(source + '\n' + exposeExports, context);
+ return context;
+}
diff --git a/tests/md-extractors.mjs b/tests/md-extractors.mjs
index 356057b2..84dfadea 100644
--- a/tests/md-extractors.mjs
+++ b/tests/md-extractors.mjs
@@ -1,6 +1,6 @@
// Source of truth for markdown link extraction. Mirror into index.html's
// `Parser.extractMarkdownLinks` and `Parser.resolveMarkdownLink`. If you edit
-// one, edit the other — the copy lives inside a single-file static app.
+// one, edit the other ظ¤ the copy lives inside a single-file static app.
export function extractMarkdownLinks(content) {
if (!content) return [];
diff --git a/tests/numeric-fn-name.test.mjs b/tests/numeric-fn-name.test.mjs
index b198892c..c6188628 100644
--- a/tests/numeric-fn-name.test.mjs
+++ b/tests/numeric-fn-name.test.mjs
@@ -3,30 +3,10 @@ import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) { return file && file.content ? file.content : ''; },
- isSanitizedPreviewRenderer() { return false; },
-};
-vm.createContext(context);
-vm.runInContext(`${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;`, context);
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
// Regression for issues #54 and #52: a numeric object-literal key like
diff --git a/tests/overview-health.test.mjs b/tests/overview-health.test.mjs
new file mode 100644
index 00000000..de84881f
--- /dev/null
+++ b/tests/overview-health.test.mjs
@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import test from 'node:test';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const htmlSource = await readFile(join(__dirname, '..', 'index.html'), 'utf8');
+const context = await loadAnalyzerContext();
+
+test('calcHealth exposes breakdown factors without changing grade math', () => {
+ const data = {
+ stats: { functions: 100, dead: 10, files: 10, connections: 20 },
+ issues: [
+ { title: '3 Circular Dependencies', items: [{}, {}, {}] },
+ { title: '2 Large Files', items: [{}, {}] },
+ ],
+ securityIssues: [{ severity: 'high' }],
+ };
+ const health = context.calcHealth(data);
+ assert.equal(typeof health.score, 'number');
+ assert.ok(Array.isArray(health.breakdown));
+ assert.equal(health.breakdown.length, 5);
+ const cycles = health.breakdown.find((f) => f.key === 'cycles');
+ assert.equal(cycles.detail, '3');
+ // Historic score uses issue-object presence (1 circular issue → −5), not item count.
+ assert.equal(cycles.penalty, 5);
+});
+
+test('topFragileFiles ranks by direct + transitive dependents', () => {
+ const files = [
+ { path: 'a.js', name: 'a.js', isCode: true },
+ { path: 'b.js', name: 'b.js', isCode: true },
+ { path: 'c.js', name: 'c.js', isCode: true },
+ { path: 'd.js', name: 'd.js', isCode: true },
+ ];
+ // a <- b <- c, a <- d => a has the widest blast
+ const connections = [
+ { source: 'a.js', target: 'b.js', fn: 'a1', count: 1 },
+ { source: 'b.js', target: 'c.js', fn: 'b1', count: 1 },
+ { source: 'a.js', target: 'd.js', fn: 'a2', count: 1 },
+ ];
+ const top = context.topFragileFiles({ files, connections }, 3);
+ assert.ok(top.length >= 1);
+ assert.equal(top[0].path, 'a.js');
+ assert.ok(top[0].total >= top[0].direct);
+});
+
+test('overview pane renders health breakdown and fragile files', () => {
+ assert.match(htmlSource, /overview-section-title'},'Health breakdown'/);
+ assert.match(htmlSource, /overview-section-title'},'Most fragile files'/);
+ assert.match(htmlSource, /var fragileFiles=useMemo/);
+});
diff --git a/tests/pascal-support.test.mjs b/tests/pascal-support.test.mjs
index 232087ed..673b05d5 100644
--- a/tests/pascal-support.test.mjs
+++ b/tests/pascal-support.test.mjs
@@ -3,29 +3,11 @@ import { readFile, readdir } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, '..');
const fixtureRoot = join(__dirname, 'fixtures', 'pascal-world');
-const html = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const start = html.indexOf('// ===== CODEFLOW_ANALYZER_START =====');
-const end = html.indexOf('// ===== CODEFLOW_ANALYZER_END =====', start);
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) { return file && file.content ? file.content : ''; },
- isSanitizedPreviewRenderer() { return false; },
-};
-
-vm.createContext(context);
-vm.runInContext(
- html.slice(start, end) + '\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;',
- context
-);
-
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
async function analyzePascalFixture() {
diff --git a/tests/security-precision.test.mjs b/tests/security-precision.test.mjs
index 5079d6c6..097e1e19 100644
--- a/tests/security-precision.test.mjs
+++ b/tests/security-precision.test.mjs
@@ -3,39 +3,10 @@ import { readdir, readFile } from 'node:fs/promises';
import { basename, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext } from './load-analyzer.mjs';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFile(join(repoRoot, 'index.html'), 'utf8');
-const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
-const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(
- `${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser; this.buildAnalysisData = buildAnalysisData;`,
- context
-);
-
+const context = await loadAnalyzerContext();
const { Parser, buildAnalysisData } = context;
async function collectFixtureFiles(root) {
diff --git a/tests/security-selfscan.test.mjs b/tests/security-selfscan.test.mjs
index cce01913..3e7144ae 100644
--- a/tests/security-selfscan.test.mjs
+++ b/tests/security-selfscan.test.mjs
@@ -13,41 +13,14 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
-import { fileURLToPath } from 'node:url';
import test from 'node:test';
-import vm from 'node:vm';
+import { loadAnalyzerContext, repoRoot } from './load-analyzer.mjs';
-const __dirname = fileURLToPath(new URL('.', import.meta.url));
-const repoRoot = join(__dirname, '..');
-const htmlSource = await readFileSync(join(repoRoot, 'index.html'), 'utf8');
+const htmlSource = readFileSync(join(repoRoot, 'index.html'), 'utf8');
const startMarker = '// ===== CODEFLOW_ANALYZER_START =====';
const endMarker = '// ===== CODEFLOW_ANALYZER_END =====';
-const parserStart = htmlSource.indexOf(startMarker);
-const parserEnd = htmlSource.indexOf(endMarker, parserStart);
-
-if (parserStart < 0 || parserEnd < 0) {
- throw new Error('Could not locate analyzer source in index.html');
-}
-
-const context = {
- console,
- TreeSitter: undefined,
- Babel: undefined,
- acorn: undefined,
- getSecurityScanContent(file) {
- return file && file.content ? file.content : '';
- },
- isSanitizedPreviewRenderer() {
- return false;
- },
-};
-
-vm.createContext(context);
-vm.runInContext(
- `${htmlSource.slice(parserStart, parserEnd)}\nthis.Parser = Parser;`,
- context
-);
+const context = await loadAnalyzerContext();
const { Parser } = context;
function detectOn(files) {
@@ -57,8 +30,6 @@ function detectOn(files) {
}
test('self-scan: analyzer block source produces no security findings', () => {
- // Synthetic standalone copy of just the analyzer block: pre-fix, the
- // scanner flagged its own detection literals here (issue #91).
const synthetic =
htmlSource.slice(htmlSource.indexOf(startMarker), htmlSource.indexOf(endMarker)) +
endMarker + '\n';
@@ -68,10 +39,6 @@ test('self-scan: analyzer block source produces no security findings', () => {
});
test('self-scan: analyzer-induced titles are gone from the real index.html report', () => {
- // These three originate exclusively from detectSecurity's own source text
- // (Shell( description strings, On Error Resume Next literals, TODO/FIXME
- // lists). Remaining advisories about the UI region (innerHTML assignments,
- // an eval() mention in a hint string) are out of scope for this fix.
const issues = detectOn([{ name: 'index.html', path: 'index.html', content: htmlSource }]);
const titles = new Set(issues.map((i) => i.title));
assert.equal(titles.has('Shell Command Execution'), false);
@@ -81,9 +48,6 @@ test('self-scan: analyzer-induced titles are gone from the real index.html repor
test('self-scan exemption preserves line numbers outside the analyzer block', () => {
const pad = Array.from({ length: 40 }, (_, i) => '// filler line ' + (i + 1)).join('\n');
- // The analyzer block itself contains eval( literals; this synthetic copy
- // places an ADDITIONAL real finding after the END marker and checks that
- // its reported line matches the original content's line numbering.
const tail = [
'module.exports = function run(userInput) {',
' return eval(userInput);',