From a68c9b5b872226cc50ab73eeab5a4bb83b10d5bc Mon Sep 17 00:00:00 2001 From: MohOneX Date: Sat, 10 Oct 2026 01:47:19 +0300 Subject: [PATCH 1/2] Add Architecture violations UI and Overview health insights. Show layer violations with click-to-file, Mermaid block inspect, and layer-rule presets in Architecture. Add Overview health breakdown and most fragile files. Keep the analyzer inline in index.html so CodeFlow stays a single-file app. --- card/README.md | 24 +- card/action.yml | 2 +- card/index.js | 10 +- card/lib/analyzer.js | 2 +- card/lib/grade.js | 30 ++ card/render/receipt-md.js | 10 +- card/render/styles.js | 10 +- index.html | 446 +++++++++++++++++-- tests/advertised-extensions.test.mjs | 22 +- tests/architecture-diagram.test.mjs | 49 +- tests/code-canvas.test.mjs | 12 +- tests/codeflow-golden.test.mjs | 30 +- tests/codeflow-repo-smoke.mjs | 28 +- tests/duplicate-function-resolution.test.mjs | 30 +- tests/grade-rank.test.mjs | 28 ++ tests/layer-violation-direction.test.mjs | 34 +- tests/load-analyzer.mjs | 72 +++ tests/md-extractors.mjs | 2 +- tests/numeric-fn-name.test.mjs | 24 +- tests/overview-health.test.mjs | 54 +++ tests/pascal-support.test.mjs | 22 +- tests/security-precision.test.mjs | 33 +- tests/security-selfscan.test.mjs | 42 +- 23 files changed, 663 insertions(+), 353 deletions(-) create mode 100644 card/lib/grade.js create mode 100644 tests/grade-rank.test.mjs create mode 100644 tests/load-analyzer.mjs create mode 100644 tests/overview-health.test.mjs 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);', From b276d4a704f6a928790f5d177652b5850063ea4e Mon Sep 17 00:00:00 2001 From: MohOneX Date: Sat, 10 Oct 2026 01:48:26 +0300 Subject: [PATCH 2/2] Add monorepo architecture profile with role-aligned layers. Detect apps/* + packages/* stacks, group them as Apps / API / Shared Packages, and stamp role-derived layers so violation scoring matches the diagram. Includes monorepo-world fixture; analyzer stays inline in index.html. --- index.html | 136 +++++++++++++++++- tests/architecture-diagram.test.mjs | 60 ++++++++ .../apps/api/src/routes/users.ts | 5 + .../apps/api/src/services/userService.ts | 7 + .../apps/web/src/app/layout.tsx | 5 + .../monorepo-world/apps/web/src/app/page.tsx | 6 + .../apps/web/src/components/HomeCard.tsx | 3 + tests/fixtures/monorepo-world/dist/bundle.js | 2 + .../packages/shared/src/format.ts | 11 ++ .../packages/shared/src/index.ts | 1 + .../monorepo-world/tests/smoke.test.mjs | 4 + tests/layer-violation-direction.test.mjs | 7 + tests/load-analyzer.mjs | 2 + 13 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/monorepo-world/apps/api/src/routes/users.ts create mode 100644 tests/fixtures/monorepo-world/apps/api/src/services/userService.ts create mode 100644 tests/fixtures/monorepo-world/apps/web/src/app/layout.tsx create mode 100644 tests/fixtures/monorepo-world/apps/web/src/app/page.tsx create mode 100644 tests/fixtures/monorepo-world/apps/web/src/components/HomeCard.tsx create mode 100644 tests/fixtures/monorepo-world/dist/bundle.js create mode 100644 tests/fixtures/monorepo-world/packages/shared/src/format.ts create mode 100644 tests/fixtures/monorepo-world/packages/shared/src/index.ts create mode 100644 tests/fixtures/monorepo-world/tests/smoke.test.mjs diff --git a/index.html b/index.html index 064b3f7d..4a500659 100644 --- a/index.html +++ b/index.html @@ -1385,6 +1385,16 @@ var l='/'+p.toLowerCase().replace(/^\/+/,''); // Test files if(l.includes('/test')||l.match(/test_\w+\.py$/)||l.match(/\w+_test\.py$/)||l.includes('conftest'))return'test'; + // Monorepo / stack roots (apps/* + packages/*) — aligned with architecture roles. + if(/\/apps\/[^/]+\/src\/app\//.test(l)||/\/apps\/[^/]+\/src\/pages\//.test(l))return'ui'; + if(/\/apps\/[^/]+\/src\/components?\//.test(l))return'components'; + if(/\/apps\/(api|server|backend|worker)(-|\/)/.test(l)||/\/apps\/(api|server|backend|worker)\//.test(l))return'services'; + if(/\/packages\//.test(l)){ + if(l.includes('/service')||l.includes('/api/')||l.includes('/controller'))return'services'; + if(l.includes('/data')||l.includes('/model')||l.includes('/store')||l.includes('/schema'))return'data'; + if(l.includes('/config')||l.includes('/settings'))return'config'; + return'utils'; + } // UI/View layer if(l.includes('/ui/')||l.includes('/views/')||l.includes('/pages/')||l.includes('/templates/')||l.includes('/static/'))return'ui'; if(l.includes('/component'))return'components'; @@ -3830,17 +3840,90 @@ } 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_MONOREPO=['Apps','API / Services','Shared Packages','Data / Storage','Configuration','Testing','Fixtures / Examples','Build Output']; var ARCHITECTURE_GROUP_ORDER_GENERIC=['Application','Shared Services / Utils','Configuration','Content / Data','Build Output','Testing','Fixtures / Examples','Storage']; +// Map diagram roles → canonical layer keys shared with detectLayer / layerOrder. +var ARCHITECTURE_ROLE_TO_LAYER={ + 'app-shell':'ui', + 'frontend-route':'ui', + 'frontend-component':'components', + 'browser-shell':'ui', + 'backend-routes':'services', + 'backend-middleware':'services', + 'backend-services':'services', + 'backend-module':'services', + 'platform-analyzer':'services', + 'api-client':'services', + 'action-entry':'services', + 'shared-module':'utils', + 'analyzer-loader':'utils', + 'analyzer-core':'utils', + 'collector':'utils', + 'git':'utils', + 'inputs':'utils', + 'pr':'utils', + 'state':'utils', + 'renderer':'utils', + 'render-support':'utils', + 'module':'utils', + 'config':'config', + 'content':'data', + 'test':'test', + 'fixture':'data', + 'build-output':'utils' +}; + +function architectureRoleToLayer(role){ + if(!role)return null; + return ARCHITECTURE_ROLE_TO_LAYER[role]||null; +} + +function getMonorepoPackagePrefix(path){ + var p=normalizeArchitecturePath(path); + var match=p.match(/^(apps|packages)\/([^/]+)\//i); + if(!match)return null; + return{ + root:match[1].toLowerCase(), + name:match[2], + prefix:match[0], + rest:p.slice(match[0].length) + }; +} + +function isMonorepoApiPackageName(name){ + return /^(api|server|backend|worker|workers|service|services)(-|$)/i.test(String(name||'')); +} + +function looksLikeMonorepoPaths(paths){ + var hasApps=false; + var hasPackages=false; + var appNames=Object.create(null); + var packageNames=Object.create(null); + (paths||[]).forEach(function(p){ + var app=p.match(/(?:^|\/)apps\/([^/]+)\//i); + if(app){hasApps=true;appNames[app[1].toLowerCase()]=true;} + var pkg=p.match(/(?:^|\/)packages\/([^/]+)\//i); + if(pkg){hasPackages=true;packageNames[pkg[1].toLowerCase()]=true;} + }); + if(hasApps&&hasPackages)return true; + if(Object.keys(appNames).length>=2)return true; + if(hasPackages&&Object.keys(packageNames).length>=2&&hasApps)return true; + return false; +} + function getArchitectureGroupOrder(profile){ if(profile==='codeflow')return ARCHITECTURE_GROUP_ORDER_CODEFLOW; if(profile==='web-app')return ARCHITECTURE_GROUP_ORDER_WEBAPP; + if(profile==='monorepo')return ARCHITECTURE_GROUP_ORDER_MONOREPO; return ARCHITECTURE_GROUP_ORDER_GENERIC; } function detectArchitectureProfile(files,framework){ var paths=(files||[]).map(function(f){return normalizeArchitecturePath(f.path||f.name).toLowerCase();}); if(paths.some(function(p){return /(^|\/)index\.html?$/i.test(p);})&&paths.some(function(p){return /(^|\/)card\/(lib|render)\//i.test(p);}))return'codeflow'; + // Stack / monorepo before web-app: apps/* + packages/* also match Next.js path heuristics. + if(looksLikeMonorepoPaths(paths))return'monorepo'; if(framework==='Next.js')return'web-app'; if(paths.some(function(p){ return /(^|\/)src\/app\//i.test(p)||/(^|\/)pages\//i.test(p)||/(^|\/)(backend|server|api|services?|middleware|routes?|platforms?)\b/i.test(p); @@ -3914,12 +3997,15 @@ function isArchitectureBackendPath(path){ var p=normalizeArchitecturePath(path).toLowerCase(); + var pkg=getMonorepoPackagePrefix(p); + if(pkg&&isMonorepoApiPackageName(pkg.name))return true; if(/(^|\/)(a-)?backend(\/|$)/.test(p))return true; if(/(^|\/)server(\/|$)/.test(p))return true; if(/(^|\/)workers?(\/|$)/.test(p))return true; if(/(^|\/)functions(\/|$)/.test(p))return true; if(/(^|\/)lambda(\/|$)/.test(p))return true; if(/^src\/app\/api\//.test(p))return false; + if(/\/src\/app\/api\//.test(p))return false; var segments=p.split('/').filter(Boolean); for(var i=0;i { + assert.equal(architectureRoleToLayer('frontend-route'), 'ui'); + assert.equal(architectureRoleToLayer('frontend-component'), 'components'); + assert.equal(architectureRoleToLayer('backend-services'), 'services'); + assert.equal(architectureRoleToLayer('shared-module'), 'utils'); + assert.equal(architectureRoleToLayer('config'), 'config'); + assert.equal(architectureRoleToLayer('test'), 'test'); +}); + +test('monorepo fixture uses stack profile, package groups, and hides build/tests', async () => { + const data = await analyzeFixture('monorepo-world'); + const diagram = data.architectureDiagram; + + assert.ok(diagram); + assert.equal(diagram.profile, 'monorepo'); + assert.equal( + detectArchitectureProfile( + [ + { path: 'apps/web/src/app/page.tsx' }, + { path: 'apps/api/src/routes/users.ts' }, + { path: 'packages/shared/src/format.ts' }, + ], + 'Next.js' + ), + 'monorepo' + ); + + const visible = getVisibleArchitectureBlocks(diagram.blocks, false, false); + const groups = new Set(visible.map((block) => block.group)); + assert.ok(groups.has('Apps'), 'expected Apps group, got: ' + [...groups].join(', ')); + assert.ok(groups.has('API / Services') || groups.has('Shared Packages')); + + const visiblePaths = visible.flatMap((block) => block.files || []); + assert.equal( + visiblePaths.some((path) => /(^|\/)dist\//i.test(path) || /(^|\/)tests\//i.test(path)), + false + ); + + const order = getArchitectureGroupOrder('monorepo'); + assert.ok(order.indexOf('Apps') < order.indexOf('Shared Packages')); + + assert.ok( + (data.layerViolations || []).some( + (v) => + /packages\/shared\//.test(v.from) && + /apps\/api\//.test(v.to) && + v.fromLayer === 'utils' && + v.toLayer === 'services' + ), + 'expected utils→services violation from shared package into api app, got: ' + + JSON.stringify(data.layerViolations || []) + ); + + const mermaid = generateMermaidBlockDiagram(diagram, false, false); + assert.doesNotMatch(mermaid, /uses \d+ calls/i); + assert.doesNotMatch(mermaid, /Route \/apps\/web/i); +}); diff --git a/tests/fixtures/monorepo-world/apps/api/src/routes/users.ts b/tests/fixtures/monorepo-world/apps/api/src/routes/users.ts new file mode 100644 index 00000000..2dc0110e --- /dev/null +++ b/tests/fixtures/monorepo-world/apps/api/src/routes/users.ts @@ -0,0 +1,5 @@ +import { listUsers } from '../services/userService'; + +export function registerUserRoutes() { + return listUsers(); +} diff --git a/tests/fixtures/monorepo-world/apps/api/src/services/userService.ts b/tests/fixtures/monorepo-world/apps/api/src/services/userService.ts new file mode 100644 index 00000000..4f600eb8 --- /dev/null +++ b/tests/fixtures/monorepo-world/apps/api/src/services/userService.ts @@ -0,0 +1,7 @@ +export function listUsers() { + return [{ id: 1, name: 'Ada' }]; +} + +export function getUser(id: number) { + return listUsers().find((user) => user.id === id) || null; +} diff --git a/tests/fixtures/monorepo-world/apps/web/src/app/layout.tsx b/tests/fixtures/monorepo-world/apps/web/src/app/layout.tsx new file mode 100644 index 00000000..49c385a2 --- /dev/null +++ b/tests/fixtures/monorepo-world/apps/web/src/app/layout.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/tests/fixtures/monorepo-world/apps/web/src/app/page.tsx b/tests/fixtures/monorepo-world/apps/web/src/app/page.tsx new file mode 100644 index 00000000..3e614871 --- /dev/null +++ b/tests/fixtures/monorepo-world/apps/web/src/app/page.tsx @@ -0,0 +1,6 @@ +import { HomeCard } from '../components/HomeCard'; +import { formatLabel } from '@repo/shared'; + +export default function Page() { + return ; +} diff --git a/tests/fixtures/monorepo-world/apps/web/src/components/HomeCard.tsx b/tests/fixtures/monorepo-world/apps/web/src/components/HomeCard.tsx new file mode 100644 index 00000000..19098384 --- /dev/null +++ b/tests/fixtures/monorepo-world/apps/web/src/components/HomeCard.tsx @@ -0,0 +1,3 @@ +export function HomeCard({ title }: { title: string }) { + return
{title}
; +} diff --git a/tests/fixtures/monorepo-world/dist/bundle.js b/tests/fixtures/monorepo-world/dist/bundle.js new file mode 100644 index 00000000..3fea6b74 --- /dev/null +++ b/tests/fixtures/monorepo-world/dist/bundle.js @@ -0,0 +1,2 @@ +// build output noise — should stay hidden by default +export const built = true; diff --git a/tests/fixtures/monorepo-world/packages/shared/src/format.ts b/tests/fixtures/monorepo-world/packages/shared/src/format.ts new file mode 100644 index 00000000..fa200035 --- /dev/null +++ b/tests/fixtures/monorepo-world/packages/shared/src/format.ts @@ -0,0 +1,11 @@ +import { getUser } from '../../../apps/api/src/services/userService'; + +export function formatLabel(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +/** Intentional upward call: shared utils → app service. */ +export function formatUser(id: number) { + const user = getUser(id); + return user ? formatLabel(user.name) : ''; +} diff --git a/tests/fixtures/monorepo-world/packages/shared/src/index.ts b/tests/fixtures/monorepo-world/packages/shared/src/index.ts new file mode 100644 index 00000000..38619a7e --- /dev/null +++ b/tests/fixtures/monorepo-world/packages/shared/src/index.ts @@ -0,0 +1 @@ +export { formatLabel, formatUser } from './format'; diff --git a/tests/fixtures/monorepo-world/tests/smoke.test.mjs b/tests/fixtures/monorepo-world/tests/smoke.test.mjs new file mode 100644 index 00000000..77595fd4 --- /dev/null +++ b/tests/fixtures/monorepo-world/tests/smoke.test.mjs @@ -0,0 +1,4 @@ +import assert from 'node:assert/strict'; +import { formatLabel } from '../packages/shared/src/format.ts'; + +assert.equal(formatLabel('ok'), 'Ok'); diff --git a/tests/layer-violation-direction.test.mjs b/tests/layer-violation-direction.test.mjs index 8a6bc2f8..02c01f18 100644 --- a/tests/layer-violation-direction.test.mjs +++ b/tests/layer-violation-direction.test.mjs @@ -50,6 +50,13 @@ test('detectLayer classifies root-level layer folders, not just nested ones', () assert.equal(Parser.detectLayer('src/app.js'), 'utils'); }); +test('detectLayer classifies monorepo apps/packages paths', () => { + assert.equal(Parser.detectLayer('apps/web/src/app/page.tsx'), 'ui'); + assert.equal(Parser.detectLayer('apps/web/src/components/HomeCard.tsx'), 'components'); + assert.equal(Parser.detectLayer('apps/api/src/services/userService.ts'), 'services'); + assert.equal(Parser.detectLayer('packages/shared/src/format.ts'), 'utils'); +}); + test('a test file importing a util or service is NOT flagged as a layer violation', () => { const testFiles = [ { path: 'tests/userService.test.ts', layer: Parser.detectLayer('tests/userService.test.ts') }, diff --git a/tests/load-analyzer.mjs b/tests/load-analyzer.mjs index 10dd8177..18e00415 100644 --- a/tests/load-analyzer.mjs +++ b/tests/load-analyzer.mjs @@ -62,6 +62,8 @@ export async function loadAnalyzerContext(expose = {}) { 'resolveArchitectureBlockIdFromTarget', 'getArchitectureLayerRulePreset', 'normalizeArchitectureLayerRule', + 'architectureRoleToLayer', + 'detectArchitectureProfile', 'calcBlast', 'calcHealth', 'topFragileFiles',