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);',