From c60183ea492605f173670537acfa356fb8196335 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Mon, 27 Apr 2026 21:53:50 -0400 Subject: [PATCH 1/2] Add CodeFlow Card GitHub Action (v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships a same-repo subdirectory action at card/ that runs codeflow's analyzer headlessly and writes a self-updating SVG card to the consuming repo's README. Same analyzer as the web app — the Action loads codeflow's index.html and runs the analyzer block in a Node vm context, guaranteeing zero drift. - Sentinel comments wrap the analyzer block (CODEFLOW_ANALYZER_*) and the derived metrics block (CODEFLOW_METRICS_*) in index.html. The existing Worker bootstrap and golden test switch to the sentinels. - Action panels: health grade w/ delta, scale (files/fns/LOC/langs) with sparklines, fragility (top blast radius), hidden costs. - Optional opt-in PR receipts: sticky comment on each merged PR with a thermal-receipt-style itemization of the merge. - State persists to .github/codeflow-card.json so sparklines and deltas populate after the second run. - Zero npm deps — ships as plain Node 20 with built-ins only. Co-Authored-By: Claude Opus 4.6 --- README.md | 29 ++++ card/README.md | 88 ++++++++++++ card/action.yml | 54 +++++++ card/index.js | 157 +++++++++++++++++++++ card/lib/analyzer.js | 74 ++++++++++ card/lib/collect.js | 99 +++++++++++++ card/lib/git.js | 49 +++++++ card/lib/inputs.js | 62 ++++++++ card/lib/pr.js | 89 ++++++++++++ card/lib/state.js | 99 +++++++++++++ card/package.json | 15 ++ card/render/card.js | 249 +++++++++++++++++++++++++++++++++ card/render/receipt-md.js | 94 +++++++++++++ card/render/receipt.js | 138 ++++++++++++++++++ card/render/sparkline.js | 67 +++++++++ card/render/theme.js | 43 ++++++ index.html | 11 +- tests/codeflow-golden.test.mjs | 6 +- 18 files changed, 1419 insertions(+), 4 deletions(-) create mode 100644 card/README.md create mode 100644 card/action.yml create mode 100644 card/index.js create mode 100644 card/lib/analyzer.js create mode 100644 card/lib/collect.js create mode 100644 card/lib/git.js create mode 100644 card/lib/inputs.js create mode 100644 card/lib/pr.js create mode 100644 card/lib/state.js create mode 100644 card/package.json create mode 100644 card/render/card.js create mode 100644 card/render/receipt-md.js create mode 100644 card/render/receipt.js create mode 100644 card/render/sparkline.js create mode 100644 card/render/theme.js diff --git a/README.md b/README.md index ffd22f5..e0572df 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,32 @@ --- +## :sparkles: New: CodeFlow Card — a slick repo-stats GitHub Action + +Drop a self-updating SVG card on your README that shows your repo's **health grade**, **scale**, **fragility (top blast-radius files)**, and **hidden costs** — recomputed on every merge. Same analyzer as the web app; zero drift. + +```yaml +# .github/workflows/codeflow-card.yml +on: { push: { branches: [main] }, pull_request: { types: [closed] }, workflow_dispatch: {} } +jobs: + card: + runs-on: ubuntu-latest + permissions: { contents: write, pull-requests: write } + steps: + - uses: actions/checkout@v4 + - uses: braedonsaunders/codeflow/card@v1 +``` + +Then in your README: + +```markdown +CodeFlow card +``` + +Optional opt-in `receipts: true` posts a thermal-receipt-style sticky comment on every merged PR, itemizing the merge: `+/- LOC`, blast-radius before/after, grade delta. See [card/README.md](./card/README.md) for full inputs. + +--- + ## Why CodeFlow? Ever opened a new codebase and felt completely lost? **CodeFlow** turns any GitHub repository or local codebase into an interactive architecture map in seconds. @@ -71,6 +97,9 @@ Color files by commit frequency to see which parts of your codebase are most act ### PR Impact Analysis Paste a PR URL to see exactly which files it affects and calculate the blast radius of proposed changes. +### CodeFlow Card (GitHub Action) +Auto-updating SVG card on your README — health grade, scale, fragility, hidden costs — recomputed every merge. Optional thermal-receipt PR comments. See [card/](./card/). + ### Markdown & Wiki-Link Graph Point CodeFlow at an Obsidian vault or any markdown directory to see notes as a connected graph. Both `[[wiki-links]]` and `[text](./relative.md)` links become edges; each note is a `note`-layer node (distinct color) with a `dependencies[]` array in the JSON export. diff --git a/card/README.md b/card/README.md new file mode 100644 index 0000000..045e80d --- /dev/null +++ b/card/README.md @@ -0,0 +1,88 @@ +# 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). + +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`. + +## Quick start + +Drop this file in `.github/workflows/codeflow-card.yml`: + +```yaml +name: CodeFlow Card +on: + push: + branches: [main] + pull_request: + types: [closed] + workflow_dispatch: + +jobs: + card: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: braedonsaunders/codeflow/card@v1 + with: + theme: dark + receipts: false # set true to post merged-PR comments +``` + +Then add this to your README: + +```markdown +CodeFlow card +``` + +The Action commits the rendered SVG to `.github/codeflow-card.svg` (overwriting it on every run) and a small history file at `.github/codeflow-card.json` that powers the sparklines and deltas. + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `output` | `.github/codeflow-card.svg` | Path to write the card SVG. | +| `state` | `.github/codeflow-card.json` | Path to the JSON history file (sparklines, deltas). | +| `theme` | `dark` | `dark` \| `light`. | +| `panels` | `grade,scale,fragility,hidden-costs` | Comma-separated list. Drop any to exclude. | +| `receipts` | `false` | Post a thermal-receipt-style sticky comment on each merged PR. | +| `sparkline-window` | `30` | Recent runs to keep in state for sparklines. | +| `pin` | `true` | Show the "Powered by codeflow" footer. | +| `commit-message` | `chore: update codeflow card [skip ci]` | Commit message used by the Action. | +| `github-token` | `${{ github.token }}` | Token for committing and posting receipts. | + +## 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. + +## PR receipts (opt-in) + +When `receipts: true`, every merged PR gets a sticky comment with a thermal-receipt-style summary of what the merge changed: + +``` +--- CODEFLOW RECEIPT --- +PR #482 @yourhandle +-------------------------- +LOC +312 +functions +4 +dead code −1 +circular deps −1 +blast radius 23 → 18 ▼ +health B+ → A- ▲ +-------------------------- + thank you for your merge +``` + +The comment is sticky (updates in place via `` marker) so re-runs don't spam the PR. + +## Notes + +- **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. +- **Privacy**: nothing leaves the runner. Same guarantee as the codeflow web app. diff --git a/card/action.yml b/card/action.yml new file mode 100644 index 0000000..684c0bf --- /dev/null +++ b/card/action.yml @@ -0,0 +1,54 @@ +name: 'CodeFlow Card' +description: 'Auto-updating SVG card of your repo health, fragility, and structure. Powered by codeflow.' +author: 'braedonsaunders' +branding: + icon: 'activity' + color: 'purple' +inputs: + output: + description: 'Path to write the card SVG.' + required: false + default: '.github/codeflow-card.svg' + state: + description: 'Path to the JSON history file used for sparklines and deltas.' + required: false + default: '.github/codeflow-card.json' + theme: + description: 'Card theme: dark | light | auto.' + required: false + default: 'dark' + panels: + description: 'Comma-separated panel list. Available: grade, scale, fragility, hidden-costs.' + required: false + default: 'grade,scale,fragility,hidden-costs' + receipts: + description: 'Post a thermal-receipt-style PR comment when a PR is merged.' + required: false + default: 'false' + sparkline-window: + description: 'How many recent runs to keep in state for sparklines/deltas.' + required: false + default: '30' + pin: + description: 'Show "Powered by codeflow" footer link.' + required: false + default: 'true' + commit-message: + description: 'Commit message used when the SVG / state file change.' + required: false + default: 'chore: update codeflow card [skip ci]' + commit-author-name: + description: 'Git commit author name.' + required: false + default: 'codeflow-card[bot]' + commit-author-email: + description: 'Git commit author email.' + required: false + default: 'codeflow-card[bot]@users.noreply.github.com' + github-token: + description: 'Token used to commit and post receipts. Defaults to the workflow token.' + required: false + default: ${{ github.token }} +runs: + using: 'node20' + main: 'index.js' diff --git a/card/index.js b/card/index.js new file mode 100644 index 0000000..5891fda --- /dev/null +++ b/card/index.js @@ -0,0 +1,157 @@ +// 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. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { loadInputs } = require('./lib/inputs.js'); +const { loadAnalyzer, locateIndexHtml } = require('./lib/analyzer.js'); +const { buildAnalyzed } = require('./lib/collect.js'); +const { readState, appendRun, writeState, snapshotFromAnalysis } = require('./lib/state.js'); +const { renderCard } = require('./render/card.js'); +const { renderReceiptMarkdown } = require('./render/receipt-md.js'); +const { commitAndPush } = require('./lib/git.js'); +const { upsertStickyComment } = require('./lib/pr.js'); + +function log(msg) { + process.stdout.write('[codeflow-card] ' + msg + '\n'); +} + +function loadEvent() { + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath || !fs.existsSync(eventPath)) return null; + try { + return JSON.parse(fs.readFileSync(eventPath, 'utf8')); + } catch { + return null; + } +} + +function repoSlug() { + return process.env.GITHUB_REPOSITORY || ''; +} + +function repoOwnerName() { + const slug = repoSlug(); + if (!slug.includes('/')) return [null, null]; + return slug.split('/'); +} + +async function run() { + const inputs = loadInputs(); + const repoRoot = process.env.GITHUB_WORKSPACE || process.cwd(); + log('analyzing ' + repoRoot); + + const actionDir = __dirname; + const indexHtmlPath = locateIndexHtml(actionDir, repoRoot); + log('analyzer source: ' + indexHtmlPath); + + const { Parser, buildAnalysisData, calcBlast, calcHealth } = loadAnalyzer(indexHtmlPath); + + const { analyzed, allFns } = await buildAnalyzed(repoRoot, Parser); + log('collected ' + analyzed.length + ' files (' + allFns.length + ' functions)'); + + const data = await buildAnalysisData({ + analyzed, + allFns, + excludePatterns: [], + progress: () => {}, + yieldFn: async () => {}, + }); + log('analysis: files=' + data.stats.files + ' fns=' + data.stats.functions + ' loc=' + data.stats.loc); + + const event = loadEvent(); + const sha = process.env.GITHUB_SHA || null; + const actor = process.env.GITHUB_ACTOR || null; + const prNumber = + (event && event.pull_request && event.pull_request.number) || + (event && event.number) || + null; + const ctx = { sha, actor, pr: prNumber }; + + const snapshot = snapshotFromAnalysis(data, { calcBlast, calcHealth }, ctx); + log('grade=' + (snapshot.grade || '?') + ' score=' + (snapshot.score == null ? '?' : snapshot.score)); + + const stateAbs = path.resolve(repoRoot, inputs.state); + const state = readState(stateAbs); + const previous = state.runs.length > 0 ? state.runs[state.runs.length - 1] : null; + + // Render card SVG. + const slug = repoSlug(); + const svg = renderCard({ + snapshot, + history: state.runs, + theme: inputs.theme, + panels: inputs.panels, + repo: slug, + sha: sha, + pin: inputs.pin, + }); + 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)); + + // Append snapshot to state. + const updatedState = appendRun(state, snapshot, inputs.sparklineWindow); + writeState(stateAbs, updatedState); + log('wrote state → ' + path.relative(repoRoot, stateAbs)); + + // Receipt comment (opt-in, only on merged PRs). + const isMergedPR = + !!event && event.pull_request && event.pull_request.merged === true && prNumber != null; + if (inputs.receipts && isMergedPR && inputs.token) { + try { + const [owner, name] = repoOwnerName(); + if (owner && name) { + const body = renderReceiptMarkdown({ + snapshot, + previous, + repo: slug, + pr: { number: prNumber, actor: event.pull_request.user && event.pull_request.user.login }, + actor, + }); + await upsertStickyComment({ + token: inputs.token, + owner, + repo: name, + issueNumber: prNumber, + body, + }); + log('posted receipt comment to PR #' + prNumber); + } + } catch (e) { + log('receipt post failed: ' + (e.message || e)); + } + } else if (inputs.receipts) { + log('receipts enabled, but not a merged PR — skipping'); + } + + // Commit changes back to the repo (skip when running outside a checkout). + if (process.env.GITHUB_ACTIONS === 'true') { + try { + const result = commitAndPush({ + cwd: repoRoot, + paths: [path.relative(repoRoot, outAbs), path.relative(repoRoot, stateAbs)], + message: inputs.commitMessage, + authorName: inputs.commitAuthorName, + authorEmail: inputs.commitAuthorEmail, + push: !isMergedPR && (process.env.GITHUB_REF_TYPE === 'branch' || (process.env.GITHUB_REF || '').startsWith('refs/heads/')), + }); + log('git: ' + (result.committed ? 'committed' : 'no changes (' + result.reason + ')')); + } catch (e) { + log('git commit failed: ' + (e.message || e)); + } + } else { + log('local mode — skipping git commit'); + } +} + +run().catch((err) => { + process.stderr.write('[codeflow-card] error: ' + (err.stack || err.message || err) + '\n'); + process.exit(1); +}); diff --git a/card/lib/analyzer.js b/card/lib/analyzer.js new file mode 100644 index 0000000..5c150a2 --- /dev/null +++ b/card/lib/analyzer.js @@ -0,0 +1,74 @@ +// 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 +// the single source of truth, lives in one file, never drifts. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const START_MARKER = '// ===== CODEFLOW_ANALYZER_START ====='; +const END_MARKER = '// ===== CODEFLOW_ANALYZER_END ====='; +const METRICS_START = '// ===== CODEFLOW_METRICS_START ====='; +const METRICS_END = '// ===== CODEFLOW_METRICS_END ====='; + +function sliceBlock(html, startMarker, endMarker, label) { + const start = html.indexOf(startMarker); + const end = html.indexOf(endMarker, start); + if (start < 0 || end < 0) { + throw new Error( + 'Could not locate ' + label + ' block. Expected ' + startMarker + ' / ' + endMarker + '.' + ); + } + return html.slice(start, end); +} + +function loadAnalyzer(htmlPath) { + const html = fs.readFileSync(htmlPath, 'utf8'); + const analyzerSource = sliceBlock(html, START_MARKER, END_MARKER, 'analyzer'); + const metricsSource = sliceBlock(html, METRICS_START, METRICS_END, 'metrics'); + + const context = { + console, + TreeSitter: undefined, + Babel: undefined, + acorn: undefined, + getSecurityScanContent(file) { + return file && file.content ? file.content : ''; + }, + isSanitizedPreviewRenderer() { + return false; + }, + }; + vm.createContext(context); + const exposeExports = + '\nthis.Parser = Parser;' + + '\nthis.buildAnalysisData = buildAnalysisData;' + + '\nthis.calcBlast = calcBlast;' + + '\nthis.calcHealth = calcHealth;'; + vm.runInContext(analyzerSource + '\n' + metricsSource + exposeExports, context, { + filename: 'codeflow-analyzer.js', + }); + + return { + Parser: context.Parser, + buildAnalysisData: context.buildAnalysisData, + calcBlast: context.calcBlast, + calcHealth: context.calcHealth, + }; +} + +function locateIndexHtml(actionDir, repoRoot) { + // 1) Adjacent to the Action (same repo as codeflow itself): + const adjacent = path.join(actionDir, '..', 'index.html'); + if (fs.existsSync(adjacent)) return adjacent; + // 2) Repo root (when the Action is run from inside codeflow's checkout): + const local = path.join(repoRoot, 'index.html'); + if (fs.existsSync(local)) return local; + throw new Error( + 'Could not find index.html. Tried ' + adjacent + ' and ' + local + '.' + ); +} + +module.exports = { loadAnalyzer, locateIndexHtml, START_MARKER, END_MARKER }; diff --git a/card/lib/collect.js b/card/lib/collect.js new file mode 100644 index 0000000..abdf765 --- /dev/null +++ b/card/lib/collect.js @@ -0,0 +1,99 @@ +// Walk a repo and collect file content + parsed functions, mirroring the shape +// the analyzer expects (see tests/codeflow-golden.test.mjs analyzeFixture). + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_IGNORES = new Set([ + '.git', + 'node_modules', + '.next', + '.nuxt', + 'dist', + 'build', + 'out', + 'coverage', + '.cache', + '.parcel-cache', + '.turbo', + '.vercel', + '.idea', + '.vscode', + '__pycache__', + '.venv', + 'venv', + 'target', + 'bin', + 'obj', +]); + +function walk(root, current, files, Parser) { + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.git')) continue; + if (DEFAULT_IGNORES.has(entry.name)) continue; + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + walk(root, full, files, Parser); + continue; + } + if (!entry.isFile()) continue; + if (!Parser.isIncluded(entry.name)) continue; + const repoPath = path.relative(root, full).split(path.sep).join('/'); + files.push({ + fullPath: full, + path: repoPath, + name: path.basename(repoPath), + folder: repoPath.includes('/') ? repoPath.slice(0, repoPath.lastIndexOf('/')) : 'root', + isCode: Parser.isCode(entry.name), + }); + } +} + +async function buildAnalyzed(repoRoot, Parser) { + const files = []; + walk(repoRoot, repoRoot, files, Parser); + files.sort((a, b) => a.path.localeCompare(b.path)); + + const analyzed = []; + const allFns = []; + for (const file of files) { + let content; + try { + content = fs.readFileSync(file.fullPath, 'utf8'); + } catch { + continue; + } + const layer = Parser.detectLayer(file.path); + const isContainer = Parser.isScriptContainer(file.path); + const actualIsCode = + file.isCode !== false && (!isContainer || Parser.hasEmbeddedCode(content, file.path)); + const functions = actualIsCode ? Parser.extract(content, file.path) : []; + analyzed.push({ + path: file.path, + name: file.name, + folder: file.folder, + content, + functions, + lines: content ? content.split('\n').length : 0, + layer, + churn: 0, + isCode: actualIsCode, + }); + if (actualIsCode) { + for (const fn of functions) { + allFns.push(Object.assign({}, fn, { folder: file.folder, layer })); + } + } + } + return { analyzed, allFns }; +} + +module.exports = { buildAnalyzed }; diff --git a/card/lib/git.js b/card/lib/git.js new file mode 100644 index 0000000..ca04038 --- /dev/null +++ b/card/lib/git.js @@ -0,0 +1,49 @@ +// Commit the SVG and JSON state file back to the repo. Uses git CLI directly. + +'use strict'; + +const { execFileSync } = require('child_process'); + +function git(args, cwd) { + return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim(); +} + +function gitSafe(args, cwd) { + try { + return git(args, cwd); + } catch (e) { + return null; + } +} + +function configureIdentity(cwd, name, email) { + git(['config', 'user.name', name], cwd); + git(['config', 'user.email', email], cwd); +} + +function commitAndPush(opts) { + const cwd = opts.cwd; + const paths = opts.paths || []; + if (paths.length === 0) return { committed: false, reason: 'no paths' }; + + configureIdentity(cwd, opts.authorName, opts.authorEmail); + + for (const p of paths) { + gitSafe(['add', p], cwd); + } + + const status = git(['status', '--porcelain', '--', ...paths], cwd); + if (!status) return { committed: false, reason: 'no changes' }; + + git(['commit', '-m', opts.message], cwd); + + if (opts.push) { + const branch = opts.branch || gitSafe(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) || 'HEAD'; + if (branch && branch !== 'HEAD') { + git(['push', 'origin', branch], cwd); + } + } + return { committed: true }; +} + +module.exports = { git, gitSafe, commitAndPush }; diff --git a/card/lib/inputs.js b/card/lib/inputs.js new file mode 100644 index 0000000..ded2847 --- /dev/null +++ b/card/lib/inputs.js @@ -0,0 +1,62 @@ +// Read GitHub Actions inputs (INPUT_) and provide a +// process.env-friendly fallback for local dry-runs. + +'use strict'; + +function readInput(name, defaultValue) { + const envKey = 'INPUT_' + name.toUpperCase().replace(/-/g, '_'); + const v = process.env[envKey]; + if (v === undefined || v === '') return defaultValue; + return v; +} + +function asBool(value, fallback) { + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return /^(true|1|yes|on)$/i.test(String(value).trim()); +} + +function asInt(value, fallback) { + const n = parseInt(String(value), 10); + return Number.isFinite(n) ? n : fallback; +} + +function asList(value, fallback) { + if (!value) return fallback; + return String(value) + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +function loadInputs() { + const output = readInput('output', '.github/codeflow-card.svg'); + const state = readInput('state', '.github/codeflow-card.json'); + const theme = readInput('theme', 'dark'); + const panels = asList(readInput('panels', ''), ['grade', 'scale', 'fragility', 'hidden-costs']); + const receipts = asBool(readInput('receipts', ''), false); + const sparklineWindow = asInt(readInput('sparkline-window', ''), 30); + const pin = asBool(readInput('pin', ''), true); + const commitMessage = readInput('commit-message', 'chore: update codeflow card [skip ci]'); + const commitAuthorName = readInput('commit-author-name', 'codeflow-card[bot]'); + const commitAuthorEmail = readInput( + 'commit-author-email', + 'codeflow-card[bot]@users.noreply.github.com' + ); + const token = readInput('github-token', process.env.GITHUB_TOKEN || ''); + return { + output, + state, + theme, + panels, + receipts, + sparklineWindow, + pin, + commitMessage, + commitAuthorName, + commitAuthorEmail, + token, + }; +} + +module.exports = { loadInputs }; diff --git a/card/lib/pr.js b/card/lib/pr.js new file mode 100644 index 0000000..424e422 --- /dev/null +++ b/card/lib/pr.js @@ -0,0 +1,89 @@ +// Post or update a sticky PR comment using the GitHub REST API. No deps. + +'use strict'; + +const https = require('https'); + +const STICKY_MARKER = ''; + +function ghRequest(opts) { + const { token, method, path, body } = opts; + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = https.request( + { + method, + hostname: 'api.github.com', + path, + headers: { + 'Authorization': 'Bearer ' + token, + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'codeflow-card-action', + 'X-GitHub-Api-Version': '2022-11-28', + ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), + }, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode >= 400) { + reject(new Error('GitHub API ' + method + ' ' + path + ' returned ' + res.statusCode + ': ' + text)); + return; + } + try { + resolve(text ? JSON.parse(text) : {}); + } catch { + resolve({ raw: text }); + } + }); + } + ); + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); +} + +async function listIssueComments({ token, owner, repo, issueNumber }) { + const out = []; + let page = 1; + while (true) { + const items = await ghRequest({ + token, + method: 'GET', + path: '/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments?per_page=100&page=' + page, + }); + if (!Array.isArray(items) || items.length === 0) break; + out.push(...items); + if (items.length < 100) break; + page += 1; + } + return out; +} + +async function upsertStickyComment(opts) { + const { token, owner, repo, issueNumber, body } = opts; + const finalBody = body.includes(STICKY_MARKER) ? body : STICKY_MARKER + '\n\n' + body; + + const existing = await listIssueComments({ token, owner, repo, issueNumber }); + const mine = existing.find((c) => c.body && c.body.includes(STICKY_MARKER)); + + if (mine) { + return ghRequest({ + token, + method: 'PATCH', + path: '/repos/' + owner + '/' + repo + '/issues/comments/' + mine.id, + body: { body: finalBody }, + }); + } + return ghRequest({ + token, + method: 'POST', + path: '/repos/' + owner + '/' + repo + '/issues/' + issueNumber + '/comments', + body: { body: finalBody }, + }); +} + +module.exports = { upsertStickyComment, STICKY_MARKER }; diff --git a/card/lib/state.js b/card/lib/state.js new file mode 100644 index 0000000..c95cde4 --- /dev/null +++ b/card/lib/state.js @@ -0,0 +1,99 @@ +// Read/write the JSON history file that powers sparklines and deltas. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +function readState(statePath) { + try { + const raw = fs.readFileSync(statePath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && Array.isArray(parsed.runs)) return parsed; + } catch { + // missing or corrupt — start fresh + } + return { version: 1, runs: [] }; +} + +function appendRun(state, snapshot, windowSize) { + const runs = state.runs.slice(); + runs.push(snapshot); + if (runs.length > windowSize) runs.splice(0, runs.length - windowSize); + return { version: 1, runs }; +} + +function writeState(statePath, state) { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n'); +} + +function topBlasts(data, calcBlast, n) { + if (!data || !Array.isArray(data.files) || typeof calcBlast !== 'function') return []; + const ranked = []; + for (const file of data.files) { + if (!file.isCode) continue; + let blast; + try { + blast = calcBlast(file.path, data.connections, data.files); + } catch { + continue; + } + const direct = (blast && blast.count) || 0; + const transitive = (blast && blast.transitiveCount) || 0; + const total = direct + transitive; + if (total === 0) continue; + ranked.push({ path: file.path, direct, transitive, total }); + } + ranked.sort((a, b) => b.total - a.total); + return ranked.slice(0, n); +} + +function snapshotFromAnalysis(data, helpers, ctx) { + const stats = (data && data.stats) || {}; + const calcBlast = helpers.calcBlast; + const calcHealth = helpers.calcHealth; + + let health = null; + if (typeof calcHealth === 'function') { + try { + health = calcHealth(data); + } catch { + health = null; + } + } + const grade = health ? health.grade : null; + const score = health ? health.score : null; + + const fragility = topBlasts(data, calcBlast, 3); + + // Hidden costs + const issues = Array.isArray(data && data.issues) ? data.issues : []; + const circular = issues.filter((i) => i && i.title && i.title.includes('Circular')).length; + const avgCoupling = stats.files > 0 ? stats.connections / stats.files : 0; + + const languageList = Array.isArray(stats.languages) ? stats.languages : []; + + return { + at: new Date().toISOString(), + sha: ctx.sha || null, + pr: ctx.pr || null, + actor: ctx.actor || null, + files: stats.files || 0, + functions: stats.functions || 0, + loc: stats.loc || 0, + languages: languageList.length, + topLanguages: languageList.slice(0, 3).map((l) => ({ ext: l.ext, pct: l.pct })), + dead: stats.dead || 0, + deadPct: stats.functions ? Math.round((stats.dead / stats.functions) * 1000) / 10 : 0, + circular, + avgCoupling: Math.round(avgCoupling * 10) / 10, + securityIssues: stats.security || 0, + grade, + score, + topBlast: fragility[0] || null, + fragility, + }; +} + +module.exports = { readState, appendRun, writeState, snapshotFromAnalysis, topBlasts }; diff --git a/card/package.json b/card/package.json new file mode 100644 index 0000000..c953f5d --- /dev/null +++ b/card/package.json @@ -0,0 +1,15 @@ +{ + "name": "codeflow-card", + "version": "1.0.0", + "private": true, + "description": "GitHub Action: auto-updating SVG card of your repo health, fragility, and structure.", + "main": "index.js", + "type": "commonjs", + "engines": { + "node": ">=20" + }, + "scripts": { + "dry-run": "node index.js" + }, + "license": "MIT" +} diff --git a/card/render/card.js b/card/render/card.js new file mode 100644 index 0000000..98c4494 --- /dev/null +++ b/card/render/card.js @@ -0,0 +1,249 @@ +// Render the SVG card. Single-file; no external libs. + +'use strict'; + +const { getTheme } = require('./theme.js'); +const { sparkline } = require('./sparkline.js'); + +const W = 720; +const PAD = 22; +const HEADER_H = 60; +const PANEL_GAP = 14; +const FOOTER_H = 36; + +function escapeXml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function fmtNum(n) { + if (n == null) return '—'; + if (typeof n !== 'number') return String(n); + if (n >= 1_000_000) return (Math.round(n / 100_000) / 10) + 'M'; + if (n >= 1_000) return (Math.round(n / 100) / 10) + 'K'; + return String(n); +} + +function delta(curr, prev) { + if (prev == null || curr == null) return null; + if (curr === prev) return null; + return { dir: curr > prev ? 'up' : 'down', from: prev, to: curr }; +} + +function gradeColor(theme, grade) { + if (!grade) return theme.textDim; + if (grade.startsWith('A')) return theme.green; + if (grade.startsWith('B')) return theme.green; + if (grade.startsWith('C')) return theme.amber; + if (grade.startsWith('D')) return theme.amber; + return theme.red; +} + +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 ''; + return ''; +} + +// ---------------- panels ---------------- + +function panelGrade(snap, prev, theme, x, y, width) { + const grade = snap.grade || '?'; + const score = typeof snap.score === 'number' ? snap.score : null; + const color = gradeColor(theme, grade); + const arrow = prev ? gradeArrow(grade, prev.grade, theme) : ''; + const prevLabel = prev && prev.grade && prev.grade !== grade ? '(was ' + escapeXml(prev.grade) + ')' : ''; + const h = 110; + return { + height: h, + body: + '' + + '' + + 'HEALTH' + + '' + + escapeXml(grade) + arrow + '' + + (score != null + ? '' + score + '' + + 'SCORE / 100' + : '') + + (prevLabel + ? '' + prevLabel + '' + : '') + + '', + }; +} + +function panelScale(snap, history, theme, x, y, width) { + const items = [ + { label: 'FILES', value: snap.files, key: 'files' }, + { label: 'FNS', value: snap.functions, key: 'functions' }, + { label: 'LOC', value: snap.loc, key: 'loc' }, + { label: 'LANGS', value: snap.languages, key: 'languages' }, + ]; + const h = 88; + const colW = (width - 32) / items.length; + const cells = items + .map((item, i) => { + const cx = 16 + i * colW; + const series = history.map((r) => r[item.key]).filter((v) => typeof v === 'number'); + const spark = series.length > 1 ? sparkline(series, { width: Math.min(80, colW - 16), height: 18, stroke: theme.spark, fill: theme.sparkBg }) : ''; + return ( + '' + + '' + item.label + '' + + '' + fmtNum(item.value) + '' + + (spark ? '' + spark + '' : '') + + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + cells + + '', + }; +} + +function panelFragility(snap, theme, x, y, width) { + const list = Array.isArray(snap.fragility) ? snap.fragility.slice(0, 3) : []; + const rowH = 18; + const headerH = 28; + const h = headerH + Math.max(rowH * 3, rowH * Math.max(list.length, 1)) + 12; + let rows = ''; + if (list.length === 0) { + rows = + 'No cross-file dependencies detected.'; + } else { + rows = list + .map((f, i) => { + const ry = headerH + 6 + i * rowH; + const name = f.path.length > 48 ? '…' + f.path.slice(-47) : f.path; + return ( + '' + + escapeXml(name) + '' + + '' + + f.direct + ' direct · ' + f.transitive + ' transitive' + ); + }) + .join(''); + } + return { + height: h, + body: + '' + + '' + + 'FRAGILITY · TOP BLAST RADIUS' + + rows + + '', + }; +} + +function panelHiddenCosts(snap, prev, theme, x, y, width) { + const items = [ + { label: 'CIRCULAR DEPS', value: snap.circular, prev: prev ? prev.circular : null, lowerIsBetter: true }, + { label: 'DEAD CODE', value: snap.deadPct + '%', raw: snap.deadPct, prev: prev ? prev.deadPct : null, lowerIsBetter: true }, + { label: 'AVG COUPLING', value: snap.avgCoupling, prev: prev ? prev.avgCoupling : null, lowerIsBetter: true }, + ]; + const h = 70; + const colW = (width - 32) / items.length; + const cells = items + .map((item, i) => { + const cx = 16 + i * colW; + let arrow = ''; + const curr = typeof item.raw === 'number' ? item.raw : item.value; + if (item.prev != null && typeof curr === 'number' && curr !== item.prev) { + const better = item.lowerIsBetter ? curr < item.prev : curr > item.prev; + const sign = curr < item.prev ? '▼' : '▲'; + const color = better ? theme.green : theme.red; + arrow = '' + sign + ''; + } + return ( + '' + + '' + item.label + '' + + '' + escapeXml(item.value) + arrow + '' + + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + cells + + '', + }; +} + +// ---------------- main ---------------- + +function renderCard(opts) { + const theme = getTheme(opts.theme || 'dark'); + const snap = opts.snapshot; + const history = opts.history || []; + const prev = history.length > 0 ? history[history.length - 1] : null; + const panels = opts.panels || ['grade', 'scale', 'fragility', 'hidden-costs']; + const repo = opts.repo || ''; + const sha = opts.sha ? opts.sha.slice(0, 7) : ''; + const showPin = opts.pin !== false; + + const innerW = W - PAD * 2; + + const blocks = []; + let cursorY = HEADER_H; + + function add(panel) { + if (!panel) return; + blocks.push(panel.body); + cursorY += panel.height + PANEL_GAP; + } + + for (const p of panels) { + if (p === 'grade') add(panelGrade(snap, prev, theme, PAD, cursorY, innerW)); + else if (p === 'scale') add(panelScale(snap, history.concat([snap]), theme, PAD, cursorY, innerW)); + else if (p === 'fragility') add(panelFragility(snap, theme, PAD, cursorY, innerW)); + else if (p === 'hidden-costs') add(panelHiddenCosts(snap, prev, theme, PAD, cursorY, innerW)); + } + + const totalH = cursorY - PANEL_GAP + FOOTER_H + 10; + + const header = + '' + + '' + escapeXml(repo || 'codeflow card') + '' + + '' + + (sha ? '@' + escapeXml(sha) : '') + '' + + ''; + + const footerY = totalH - 14; + const updated = new Date().toISOString().slice(0, 10); + const footer = + '' + + 'updated ' + updated + '' + + (showPin + ? 'powered by ' + + 'codeflow' + : '') + + ''; + + return ( + '' + + '' + + header + + blocks.join('') + + footer + + '' + ); +} + +module.exports = { renderCard, escapeXml, fmtNum }; diff --git a/card/render/receipt-md.js b/card/render/receipt-md.js new file mode 100644 index 0000000..42a5f9d --- /dev/null +++ b/card/render/receipt-md.js @@ -0,0 +1,94 @@ +// Markdown version of the merged-PR receipt. GitHub PR comments are markdown, +// so the comment uses this; the SVG variant is retained for future image use. + +'use strict'; + +function diff(curr, prev) { + if (prev == null || curr == null) return null; + if (typeof curr !== 'number' || typeof prev !== 'number') return null; + const d = curr - prev; + if (d === 0) return null; + return d; +} + +function fmtSigned(n) { + if (n == null) return '—'; + return (n > 0 ? '+' : '') + n; +} + +function dirArrow(n, lowerIsBetter) { + if (n == null || n === 0) return ''; + const better = lowerIsBetter ? n < 0 : n > 0; + const sym = n < 0 ? '▼' : '▲'; + return better ? ' :small_blue_diamond: ' + sym : ' :warning: ' + sym; +} + +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 ' ▼'; + return ''; +} + +function renderReceiptMarkdown(opts) { + const snap = opts.snapshot; + const prev = opts.previous; + const repo = opts.repo || ''; + const pr = opts.pr || {}; + const actor = opts.actor || pr.actor || ''; + const number = pr.number ? '#' + pr.number : ''; + + const lines = []; + lines.push('```'); + lines.push('--- CODEFLOW RECEIPT ---'); + if (number || actor) { + lines.push('PR ' + number + (actor ? ' @' + actor : '')); + } + if (repo) lines.push(repo); + lines.push('--------------------------'); + + const dLoc = diff(snap.loc, prev && prev.loc); + const dFns = diff(snap.functions, prev && prev.functions); + const dDead = diff(snap.dead, prev && prev.dead); + const dCirc = diff(snap.circular, prev && prev.circular); + + function pushRow(label, value) { + const labelPad = label.padEnd(14, ' '); + lines.push(labelPad + value); + } + + if (dLoc != null) pushRow('LOC', fmtSigned(dLoc)); + else pushRow('LOC', String(snap.loc)); + if (dFns != null) pushRow('functions', fmtSigned(dFns)); + else pushRow('functions', String(snap.functions)); + if (dDead != null) pushRow('dead code', fmtSigned(dDead)); + if (dCirc != null) pushRow('circular deps', fmtSigned(dCirc)); + + if (snap.topBlast) { + if (prev && prev.topBlast && prev.topBlast.total !== snap.topBlast.total) { + const d = snap.topBlast.total - prev.topBlast.total; + pushRow('blast radius', prev.topBlast.total + ' → ' + snap.topBlast.total + (d < 0 ? ' ▼' : ' ▲')); + } else { + pushRow('blast radius', String(snap.topBlast.total)); + } + } + if (snap.grade) { + if (prev && prev.grade && prev.grade !== snap.grade) { + pushRow('health', prev.grade + ' → ' + snap.grade + gradeArrow(snap.grade, prev.grade)); + } else { + pushRow('health', snap.grade); + } + } + + lines.push('--------------------------'); + lines.push(' thank you for your merge'); + lines.push('```'); + lines.push(''); + lines.push('_powered by [codeflow](https://github.com/braedonsaunders/codeflow)_'); + return lines.join('\n'); +} + +module.exports = { renderReceiptMarkdown }; diff --git a/card/render/receipt.js b/card/render/receipt.js new file mode 100644 index 0000000..ba44c23 --- /dev/null +++ b/card/render/receipt.js @@ -0,0 +1,138 @@ +// Render the merged-PR "thermal receipt" SVG. Itemizes the merge as a +// shareable artifact. + +'use strict'; + +const { getTheme } = require('./theme.js'); +const { escapeXml } = require('./card.js'); + +function diff(curr, prev) { + if (prev == null || curr == null) return null; + if (typeof curr !== 'number' || typeof prev !== 'number') return null; + const d = curr - prev; + if (d === 0) return null; + return d; +} + +function fmtSigned(n, opts) { + if (n == null) return '—'; + const sign = n > 0 ? '+' : ''; + const v = (opts && opts.suffix) ? sign + n + opts.suffix : sign + n; + return v; +} + +function arrow(n, lowerIsBetter, theme) { + if (n == null || n === 0) return { sym: '', color: theme.textDim }; + const better = lowerIsBetter ? n < 0 : n > 0; + return { + sym: n < 0 ? '▼' : '▲', + color: better ? theme.green : theme.red, + }; +} + +function row(label, value, color, theme) { + return { label, value, color: color || theme.text }; +} + +function renderReceipt(opts) { + const theme = getTheme(opts.theme || 'dark'); + const snap = opts.snapshot; + const prev = opts.previous; + const repo = opts.repo || ''; + const pr = opts.pr || {}; + const actor = opts.actor || pr.actor || 'unknown'; + const number = pr.number ? '#' + pr.number : ''; + + const dLoc = diff(snap.loc, prev && prev.loc); + const dFns = diff(snap.functions, prev && prev.functions); + const dDead = diff(snap.dead, prev && prev.dead); + const dCirc = diff(snap.circular, prev && prev.circular); + const dCoup = prev ? Math.round((snap.avgCoupling - prev.avgCoupling) * 10) / 10 : null; + + const rows = []; + rows.push(row('PR', escapeXml(number + (actor ? ' @' + actor : '')), theme.text, theme)); + + if (dLoc != null) { + const a = arrow(dLoc, false, theme); + rows.push(row('LOC', fmtSigned(dLoc), a.color, theme)); + } else { + rows.push(row('LOC', String(snap.loc), theme.text, theme)); + } + if (dFns != null) { + const a = arrow(dFns, false, theme); + rows.push(row('functions', fmtSigned(dFns), a.color, theme)); + } else { + rows.push(row('functions', String(snap.functions), theme.text, theme)); + } + if (dDead != null) { + const a = arrow(dDead, true, theme); + rows.push(row('dead code', fmtSigned(dDead), a.color, theme)); + } + if (dCirc != null) { + const a = arrow(dCirc, true, theme); + rows.push(row('circular deps', fmtSigned(dCirc), a.color, theme)); + } + if (dCoup != null && dCoup !== 0) { + const a = arrow(dCoup, true, theme); + rows.push(row('coupling', fmtSigned(dCoup), a.color, theme)); + } + if (snap.topBlast) { + const prevTop = prev && prev.topBlast ? prev.topBlast.total : null; + const currTop = snap.topBlast.total; + if (prevTop != null && currTop !== prevTop) { + const a = arrow(currTop - prevTop, true, theme); + rows.push(row('blast radius', prevTop + ' → ' + currTop + ' ' + a.sym, a.color, theme)); + } else { + rows.push(row('blast radius', String(currTop), theme.text, theme)); + } + } + if (snap.grade) { + if (prev && prev.grade && prev.grade !== snap.grade) { + const order = ['F', 'D', 'C', 'B', 'A']; + const better = order.indexOf(snap.grade[0]) > order.indexOf(prev.grade[0]); + const color = better ? theme.green : theme.red; + rows.push(row('health', prev.grade + ' → ' + snap.grade, color, theme)); + } else { + rows.push(row('health', snap.grade, theme.text, theme)); + } + } + + const W = 360; + const PAD = 18; + const headerH = 64; + const rowH = 22; + const footerH = 50; + const totalH = headerH + rows.length * rowH + footerH; + + const dashes = + ''; + + const rowsSvg = rows + .map((r, i) => { + const y = headerH + i * rowH; + return ( + '' + + escapeXml(r.label) + + '' + + '' + + r.value + + '' + ); + }) + .join(''); + + return ( + '' + + '' + + 'CODEFLOW RECEIPT' + + '' + escapeXml(repo) + '' + + '' + dashes + '' + + rowsSvg + + '' + dashes + '' + + 'thank you for your merge' + + 'powered by codeflow' + + '' + ); +} + +module.exports = { renderReceipt }; diff --git a/card/render/sparkline.js b/card/render/sparkline.js new file mode 100644 index 0000000..47538b4 --- /dev/null +++ b/card/render/sparkline.js @@ -0,0 +1,67 @@ +// Tiny inline SVG sparkline. No external lib. + +'use strict'; + +function sparkline(values, opts) { + const o = opts || {}; + const width = o.width || 80; + const height = o.height || 18; + const stroke = o.stroke || '#a78bfa'; + const fill = o.fill || 'rgba(167,139,250,0.18)'; + if (!Array.isArray(values) || values.length === 0) { + return ''; + } + const data = values.map((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)); + if (data.length === 1) data.unshift(data[0]); + let min = Math.min.apply(null, data); + let max = Math.max.apply(null, data); + if (min === max) { + min -= 1; + max += 1; + } + const range = max - min; + const stepX = data.length > 1 ? width / (data.length - 1) : width; + const points = data.map((v, i) => { + const x = i * stepX; + const y = height - ((v - min) / range) * height; + return [Math.round(x * 100) / 100, Math.round(y * 100) / 100]; + }); + const linePath = points + .map((p, i) => (i === 0 ? 'M' + p[0] + ' ' + p[1] : 'L' + p[0] + ' ' + p[1])) + .join(' '); + const areaPath = + linePath + + ' L' + + points[points.length - 1][0] + + ' ' + + height + + ' L' + + points[0][0] + + ' ' + + height + + ' Z'; + return ( + '' + + '' + + '' + + '' + ); +} + +module.exports = { sparkline }; diff --git a/card/render/theme.js b/card/render/theme.js new file mode 100644 index 0000000..0e4606a --- /dev/null +++ b/card/render/theme.js @@ -0,0 +1,43 @@ +// Color tokens. Codeflow's site uses a dark indigo/purple palette; we mirror +// those values so the card looks like family. + +'use strict'; + +const DARK = { + bg: '#0d1117', + bgAlt: '#161b22', + border: '#21262d', + text: '#e6edf3', + textDim: '#8b949e', + textFaint: '#6e7681', + accent: '#a78bfa', // codeflow purple + accentSoft: 'rgba(167,139,250,0.16)', + green: '#3fb950', + amber: '#d29922', + red: '#f85149', + spark: '#a78bfa', + sparkBg: 'rgba(167,139,250,0.18)', +}; + +const LIGHT = { + bg: '#ffffff', + bgAlt: '#f6f8fa', + border: '#d0d7de', + text: '#1f2328', + textDim: '#656d76', + textFaint: '#8c959f', + accent: '#6f42c1', + accentSoft: 'rgba(111,66,193,0.12)', + green: '#1a7f37', + amber: '#9a6700', + red: '#cf222e', + spark: '#6f42c1', + sparkBg: 'rgba(111,66,193,0.12)', +}; + +function getTheme(name) { + if (name === 'light') return LIGHT; + return DARK; +} + +module.exports = { getTheme, DARK, LIGHT }; diff --git a/index.html b/index.html index 034d5ab..da7eee7 100644 --- a/index.html +++ b/index.html @@ -762,6 +762,7 @@ // Parser And Static Analysis // --------------------------------------------------------------------------- +// ===== CODEFLOW_ANALYZER_START ===== const Parser={ // Tree-sitter parsers are loaded lazily from CDN and used when a language has // a stable grammar path. Regex remains an explicit fallback, not a silent lie. @@ -3272,8 +3273,11 @@ if(!response.ok)throw new Error('Unable to load analyzer source'); return response.text(); }).then(function(html){ - var parserStart=html.indexOf('const Parser={'); - var parserEnd=html.indexOf('\nfunction calcBlast',parserStart); + // Build markers via concatenation so they don't match themselves in the slice. + var startMarker='// ===== CODEFLOW_'+'ANALYZER_START ====='; + var endMarker='// ===== CODEFLOW_'+'ANALYZER_END ====='; + var parserStart=html.indexOf(startMarker); + var parserEnd=html.indexOf(endMarker,parserStart); if(parserStart<0||parserEnd<0)throw new Error('Analyzer source markers missing'); var analyzerSource=html.slice(parserStart,parserEnd); return [ @@ -3345,11 +3349,13 @@ return buildAnalysisData(options); }); } +// ===== CODEFLOW_ANALYZER_END ===== // --------------------------------------------------------------------------- // Visualization Helpers // --------------------------------------------------------------------------- +// ===== CODEFLOW_METRICS_START ===== function calcBlast(fileId,conns,files){ // Comprehensive impact analysis for a file // Connection format: {source: fileDefiningFn, target: fileCallingFn, fn: fnName, count: callCount} @@ -3454,6 +3460,7 @@ 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}; } +// ===== CODEFLOW_METRICS_END ===== function calcPRRisk(prData, repoData) { if (!prData || !repoData) return { score: 0, level: 'low', factors: [] }; diff --git a/tests/codeflow-golden.test.mjs b/tests/codeflow-golden.test.mjs index 42f63f9..7ccf536 100644 --- a/tests/codeflow-golden.test.mjs +++ b/tests/codeflow-golden.test.mjs @@ -8,8 +8,10 @@ import vm from 'node:vm'; 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); +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'); From 65ed0587bb9c527d19580ce03cac1ca5bc6e66c2 Mon Sep 17 00:00:00 2001 From: Braedon Saunders Date: Mon, 27 Apr 2026 22:40:24 -0400 Subject: [PATCH 2/2] Add 5 styles, accent/privacy controls, footer link, README gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style variants (selected via the new `style:` input, default `compact`): - compact 720x140 Grade left, scale stats right - row 720x60 Status-bar strip - minimal 720x40 Single mono text line - hero 720x200 Splashy gradient + grade + 2x2 grid - detailed 720x900+ Information-rich: grade, scale, languages bar, composition (connections, tests, folders, fn stats, patterns), top folders, fragility, hidden costs Privacy controls so a public README doesn't have to display judgment: - show-grade: false hides the letter grade everywhere - show-score: false hides the /100 score, keeps the letter Configurable accent (sparklines, link, pin): - accent: purple|teal|cyan|green|pink|blue|amber|red - accent: any CSS color, e.g. #ff6b6b Footer "powered by codeflow" is now wrapped in linking to the codeflow repo. Falls back to plain text in contexts where SVG links don't fire. State snapshot expanded to capture top folders, top languages with %, function size stats, test file ratio, patterns, duplicates, layer violations — all surfaced in `detailed`. card/examples/ holds 10 ready-rendered SVGs (compact, compact-private, compact-teal, compact-pink, row, minimal, hero, hero-private, detailed, detailed-private) generated against this repo, embedded in the README as a style gallery. Co-Authored-By: Claude Opus 4.6 --- README.md | 49 +++ card/action.yml | 20 +- card/examples/compact-pink.svg | 1 + card/examples/compact-private.svg | 1 + card/examples/compact-teal.svg | 1 + card/examples/compact.svg | 1 + card/examples/detailed-private.svg | 1 + card/examples/detailed.svg | 1 + card/examples/hero-private.svg | 1 + card/examples/hero.svg | 1 + card/examples/minimal.svg | 1 + card/examples/row.svg | 1 + card/index.js | 4 + card/lib/inputs.js | 12 +- card/lib/state.js | 49 ++- card/render/card.js | 246 +---------- card/render/styles.js | 656 +++++++++++++++++++++++++++++ card/render/theme.js | 51 ++- 18 files changed, 848 insertions(+), 249 deletions(-) create mode 100644 card/examples/compact-pink.svg create mode 100644 card/examples/compact-private.svg create mode 100644 card/examples/compact-teal.svg create mode 100644 card/examples/compact.svg create mode 100644 card/examples/detailed-private.svg create mode 100644 card/examples/detailed.svg create mode 100644 card/examples/hero-private.svg create mode 100644 card/examples/hero.svg create mode 100644 card/examples/minimal.svg create mode 100644 card/examples/row.svg create mode 100644 card/render/styles.js diff --git a/README.md b/README.md index e0572df..167085b 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,55 @@ node --test tests/ --- +## Card Style Gallery + +All examples below are real cards rendered by the [CodeFlow Card Action](./card/) against this very repo. Pick one and drop it on your README. + +### `style: compact` — default + +compact + +### `style: compact` with `show-grade: false, show-score: false` + +For public READMEs where you'd rather show data than a letter grade. The card stays informational — files, functions, LOC, languages, tests — without the judgmental bits. + +compact private + +### `accent` — any preset or CSS color + +The accent recolors the sparklines, links, and pin. Presets: `purple` (default), `teal`, `cyan`, `green`, `pink`, `blue`, `amber`, `red`. Or pass any CSS color (e.g. `#ff6b6b`). + +compact teal +compact pink + +### `style: row` — status-bar strip + +row + +### `style: minimal` — single text line + +minimal + +### `style: hero` — splashy gradient + +hero + +`hero` with `show-grade: false`: + +hero private + +### `style: detailed` — information-rich + +Everything: grade, scale, language breakdown, composition (connections, tests, folders, function stats, patterns), top folders, fragility, hidden costs. + +detailed + +`detailed` with `show-grade: false`: + +detailed private + +--- + ## Star History If you find CodeFlow useful, please star the repo. diff --git a/card/action.yml b/card/action.yml index 684c0bf..b386dec 100644 --- a/card/action.yml +++ b/card/action.yml @@ -17,10 +17,26 @@ inputs: description: 'Card theme: dark | light | auto.' required: false default: 'dark' + accent: + description: 'Accent color. Either a preset (purple|teal|cyan|green|pink|blue|amber|red) or any CSS color (e.g. #ff6b6b).' + required: false + default: '' + style: + description: 'Card style: compact | row | minimal | hero | detailed.' + required: false + default: 'compact' panels: - description: 'Comma-separated panel list. Available: grade, scale, fragility, hidden-costs.' + description: 'Only used when style=detailed. Comma-separated: grade, scale, languages, fragility, hidden-costs, patterns.' + required: false + default: 'grade,scale' + show-grade: + description: 'Show the letter grade. Set to false on public READMEs to keep the card informational without judgment.' required: false - default: 'grade,scale,fragility,hidden-costs' + default: 'true' + show-score: + description: 'Show the numeric score. Set to false to hide the granular health number while keeping the letter.' + required: false + default: 'true' receipts: description: 'Post a thermal-receipt-style PR comment when a PR is merged.' required: false diff --git a/card/examples/compact-pink.svg b/card/examples/compact-pink.svg new file mode 100644 index 0000000..d0ba04c --- /dev/null +++ b/card/examples/compact-pink.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryHEALTHB80/100FILES38FNS555LOC10.2KLANGS7powered by codeflow \ No newline at end of file diff --git a/card/examples/compact-private.svg b/card/examples/compact-private.svg new file mode 100644 index 0000000..1002019 --- /dev/null +++ b/card/examples/compact-private.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryFILES38FNS555LOC10.2KLANGS7TESTS3powered by codeflow \ No newline at end of file diff --git a/card/examples/compact-teal.svg b/card/examples/compact-teal.svg new file mode 100644 index 0000000..de2443d --- /dev/null +++ b/card/examples/compact-teal.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryHEALTHB80/100FILES38FNS555LOC10.2KLANGS7powered by codeflow \ No newline at end of file diff --git a/card/examples/compact.svg b/card/examples/compact.svg new file mode 100644 index 0000000..2ea4755 --- /dev/null +++ b/card/examples/compact.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryHEALTHB80/100FILES38FNS555LOC10.2KLANGS7powered by codeflow \ No newline at end of file diff --git a/card/examples/detailed-private.svg b/card/examples/detailed-private.svg new file mode 100644 index 0000000..1202bf1 --- /dev/null +++ b/card/examples/detailed-private.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryFILES38FNS555LOC10.2KLANGS7LANGUAGES.html67%.js19%.mjs8%.md5%.yml1%CONNECTIONS142TEST FILES3FOLDERS3AVG FN LINES15.8LONGEST FN16LPATTERNS7TOP FOLDERS · BY FILE COUNTtests20card16root2FRAGILITY · TOP BLAST RADIUSindex.html20 direct · 25 transitivecard/lib/collect.js5 direct · 24 transitivetests/fixtures/golden-world/src/math.js2 direct · 26 transitiveupdated 2026-04-28powered by codeflow \ No newline at end of file diff --git a/card/examples/detailed.svg b/card/examples/detailed.svg new file mode 100644 index 0000000..a5a4eac --- /dev/null +++ b/card/examples/detailed.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow@galleryHEALTHB80SCORE / 100FILES38FNS555LOC10.2KLANGS7LANGUAGES.html67%.js19%.mjs8%.md5%.yml1%CONNECTIONS142TEST FILES3FOLDERS3AVG FN LINES15.8LONGEST FN16LPATTERNS7TOP FOLDERS · BY FILE COUNTtests20card16root2FRAGILITY · TOP BLAST RADIUSindex.html20 direct · 25 transitivecard/lib/collect.js5 direct · 24 transitivetests/fixtures/golden-world/src/math.js2 direct · 26 transitiveCIRCULAR DEPS1DEAD CODE0.7%AVG COUPLING3.7updated 2026-04-28powered by codeflow \ No newline at end of file diff --git a/card/examples/hero-private.svg b/card/examples/hero-private.svg new file mode 100644 index 0000000..f5717ab --- /dev/null +++ b/card/examples/hero-private.svg @@ -0,0 +1 @@ +CODEFLOW · CODEBASE OVERVIEWbraedonsaunders/codeflow@galleryFILES38FUNCTIONS555LINES OF CODE10.2KLANGUAGES7TEST FILES3CONNECTIONS142powered by codeflow \ No newline at end of file diff --git a/card/examples/hero.svg b/card/examples/hero.svg new file mode 100644 index 0000000..b40b682 --- /dev/null +++ b/card/examples/hero.svg @@ -0,0 +1 @@ +CODEFLOW · HEALTH REPORTbraedonsaunders/codeflow@galleryB80SCORE / 100FILES38FUNCTIONS555LINES OF CODE10.2KLANGUAGES7powered by codeflow \ No newline at end of file diff --git a/card/examples/minimal.svg b/card/examples/minimal.svg new file mode 100644 index 0000000..46998a3 --- /dev/null +++ b/card/examples/minimal.svg @@ -0,0 +1 @@ +braedonsaunders/codeflow · B · 38 files · 555 fns · 10.2K LOC · 7 langscodeflow \ No newline at end of file diff --git a/card/examples/row.svg b/card/examples/row.svg new file mode 100644 index 0000000..7ccc8e8 --- /dev/null +++ b/card/examples/row.svg @@ -0,0 +1 @@ +Bbraedonsaunders/codeflow38 files · 555 fns · 10.2K LOC · 7 langs · 3 tests · score 80codeflow \ No newline at end of file diff --git a/card/index.js b/card/index.js index 5891fda..bc404b7 100644 --- a/card/index.js +++ b/card/index.js @@ -86,7 +86,11 @@ async function run() { snapshot, history: state.runs, theme: inputs.theme, + accent: inputs.accent, + style: inputs.style, panels: inputs.panels, + showGrade: inputs.showGrade, + showScore: inputs.showScore, repo: slug, sha: sha, pin: inputs.pin, diff --git a/card/lib/inputs.js b/card/lib/inputs.js index ded2847..4ac4338 100644 --- a/card/lib/inputs.js +++ b/card/lib/inputs.js @@ -33,7 +33,13 @@ function loadInputs() { const output = readInput('output', '.github/codeflow-card.svg'); const state = readInput('state', '.github/codeflow-card.json'); const theme = readInput('theme', 'dark'); - const panels = asList(readInput('panels', ''), ['grade', 'scale', 'fragility', 'hidden-costs']); + const accent = readInput('accent', ''); + const style = readInput('style', 'compact'); + // panels only used when style=detailed. Empty means "show everything for the style". + const panels = asList(readInput('panels', ''), []); + // Privacy: hide judgmental metrics on a publicly displayed README. + const showGrade = asBool(readInput('show-grade', ''), true); + const showScore = asBool(readInput('show-score', ''), true); const receipts = asBool(readInput('receipts', ''), false); const sparklineWindow = asInt(readInput('sparkline-window', ''), 30); const pin = asBool(readInput('pin', ''), true); @@ -48,7 +54,11 @@ function loadInputs() { output, state, theme, + accent, + style, panels, + showGrade, + showScore, receipts, sparklineWindow, pin, diff --git a/card/lib/state.js b/card/lib/state.js index c95cde4..1d2be65 100644 --- a/card/lib/state.js +++ b/card/lib/state.js @@ -67,11 +67,45 @@ function snapshotFromAnalysis(data, helpers, ctx) { const fragility = topBlasts(data, calcBlast, 3); - // Hidden costs + // Issues breakdown const issues = Array.isArray(data && data.issues) ? data.issues : []; const circular = issues.filter((i) => i && i.title && i.title.includes('Circular')).length; + const godObjects = issues.filter((i) => i && i.title && i.title.includes('Large')).length; const avgCoupling = stats.files > 0 ? stats.connections / stats.files : 0; + // Top folders by file count + let topFolders = []; + if (Array.isArray(data && data.files)) { + const counts = new Map(); + for (const f of data.files) { + const top = (f.folder || 'root').split('/')[0] || 'root'; + counts.set(top, (counts.get(top) || 0) + 1); + } + topFolders = Array.from(counts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + } + + // Function size stats — derive from `code` if `lines` isn't populated. + const fnLines = Array.isArray(data && data.functions) + ? data.functions + .map((fn) => fn.lines || (fn.code ? fn.code.split('\n').length : 0)) + .filter((n) => n > 0) + : []; + const avgFnLines = fnLines.length + ? Math.round((fnLines.reduce((a, b) => a + b, 0) / fnLines.length) * 10) / 10 + : 0; + const longestFn = fnLines.length ? Math.max.apply(null, fnLines) : 0; + + // Test ratio (heuristic) + let testFiles = 0; + if (Array.isArray(data && data.files)) { + for (const f of data.files) { + if (/\.(test|spec)\.|\/(tests?|specs?|__tests__)\//i.test(f.path)) testFiles += 1; + } + } + const languageList = Array.isArray(stats.languages) ? stats.languages : []; return { @@ -83,12 +117,23 @@ function snapshotFromAnalysis(data, helpers, ctx) { functions: stats.functions || 0, loc: stats.loc || 0, languages: languageList.length, - topLanguages: languageList.slice(0, 3).map((l) => ({ ext: l.ext, pct: l.pct })), + topLanguages: languageList.slice(0, 5).map((l) => ({ ext: l.ext, pct: l.pct, lines: l.lines })), dead: stats.dead || 0, deadPct: stats.functions ? Math.round((stats.dead / stats.functions) * 1000) / 10 : 0, circular, + godObjects, avgCoupling: Math.round(avgCoupling * 10) / 10, securityIssues: stats.security || 0, + patterns: stats.patterns || 0, + duplicates: stats.duplicates || 0, + layerViolations: stats.violations || 0, + connections: stats.connections || 0, + avgFnLines, + longestFn, + testFiles, + testRatio: stats.files ? Math.round((testFiles / stats.files) * 1000) / 10 : 0, + topFolders, + folders: topFolders.length, grade, score, topBlast: fragility[0] || null, diff --git a/card/render/card.js b/card/render/card.js index 98c4494..e734d94 100644 --- a/card/render/card.js +++ b/card/render/card.js @@ -1,249 +1,15 @@ -// Render the SVG card. Single-file; no external libs. +// Card entry point. Dispatches to the requested style; see styles.js for +// individual renderers. 'use strict'; -const { getTheme } = require('./theme.js'); -const { sparkline } = require('./sparkline.js'); - -const W = 720; -const PAD = 22; -const HEADER_H = 60; -const PANEL_GAP = 14; -const FOOTER_H = 36; - -function escapeXml(str) { - return String(str == null ? '' : str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function fmtNum(n) { - if (n == null) return '—'; - if (typeof n !== 'number') return String(n); - if (n >= 1_000_000) return (Math.round(n / 100_000) / 10) + 'M'; - if (n >= 1_000) return (Math.round(n / 100) / 10) + 'K'; - return String(n); -} - -function delta(curr, prev) { - if (prev == null || curr == null) return null; - if (curr === prev) return null; - return { dir: curr > prev ? 'up' : 'down', from: prev, to: curr }; -} - -function gradeColor(theme, grade) { - if (!grade) return theme.textDim; - if (grade.startsWith('A')) return theme.green; - if (grade.startsWith('B')) return theme.green; - if (grade.startsWith('C')) return theme.amber; - if (grade.startsWith('D')) return theme.amber; - return theme.red; -} - -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 ''; - return ''; -} - -// ---------------- panels ---------------- - -function panelGrade(snap, prev, theme, x, y, width) { - const grade = snap.grade || '?'; - const score = typeof snap.score === 'number' ? snap.score : null; - const color = gradeColor(theme, grade); - const arrow = prev ? gradeArrow(grade, prev.grade, theme) : ''; - const prevLabel = prev && prev.grade && prev.grade !== grade ? '(was ' + escapeXml(prev.grade) + ')' : ''; - const h = 110; - return { - height: h, - body: - '' + - '' + - 'HEALTH' + - '' + - escapeXml(grade) + arrow + '' + - (score != null - ? '' + score + '' + - 'SCORE / 100' - : '') + - (prevLabel - ? '' + prevLabel + '' - : '') + - '', - }; -} - -function panelScale(snap, history, theme, x, y, width) { - const items = [ - { label: 'FILES', value: snap.files, key: 'files' }, - { label: 'FNS', value: snap.functions, key: 'functions' }, - { label: 'LOC', value: snap.loc, key: 'loc' }, - { label: 'LANGS', value: snap.languages, key: 'languages' }, - ]; - const h = 88; - const colW = (width - 32) / items.length; - const cells = items - .map((item, i) => { - const cx = 16 + i * colW; - const series = history.map((r) => r[item.key]).filter((v) => typeof v === 'number'); - const spark = series.length > 1 ? sparkline(series, { width: Math.min(80, colW - 16), height: 18, stroke: theme.spark, fill: theme.sparkBg }) : ''; - return ( - '' + - '' + item.label + '' + - '' + fmtNum(item.value) + '' + - (spark ? '' + spark + '' : '') + - '' - ); - }) - .join(''); - return { - height: h, - body: - '' + - '' + - cells + - '', - }; -} - -function panelFragility(snap, theme, x, y, width) { - const list = Array.isArray(snap.fragility) ? snap.fragility.slice(0, 3) : []; - const rowH = 18; - const headerH = 28; - const h = headerH + Math.max(rowH * 3, rowH * Math.max(list.length, 1)) + 12; - let rows = ''; - if (list.length === 0) { - rows = - 'No cross-file dependencies detected.'; - } else { - rows = list - .map((f, i) => { - const ry = headerH + 6 + i * rowH; - const name = f.path.length > 48 ? '…' + f.path.slice(-47) : f.path; - return ( - '' + - escapeXml(name) + '' + - '' + - f.direct + ' direct · ' + f.transitive + ' transitive' - ); - }) - .join(''); - } - return { - height: h, - body: - '' + - '' + - 'FRAGILITY · TOP BLAST RADIUS' + - rows + - '', - }; -} - -function panelHiddenCosts(snap, prev, theme, x, y, width) { - const items = [ - { label: 'CIRCULAR DEPS', value: snap.circular, prev: prev ? prev.circular : null, lowerIsBetter: true }, - { label: 'DEAD CODE', value: snap.deadPct + '%', raw: snap.deadPct, prev: prev ? prev.deadPct : null, lowerIsBetter: true }, - { label: 'AVG COUPLING', value: snap.avgCoupling, prev: prev ? prev.avgCoupling : null, lowerIsBetter: true }, - ]; - const h = 70; - const colW = (width - 32) / items.length; - const cells = items - .map((item, i) => { - const cx = 16 + i * colW; - let arrow = ''; - const curr = typeof item.raw === 'number' ? item.raw : item.value; - if (item.prev != null && typeof curr === 'number' && curr !== item.prev) { - const better = item.lowerIsBetter ? curr < item.prev : curr > item.prev; - const sign = curr < item.prev ? '▼' : '▲'; - const color = better ? theme.green : theme.red; - arrow = '' + sign + ''; - } - return ( - '' + - '' + item.label + '' + - '' + escapeXml(item.value) + arrow + '' + - '' - ); - }) - .join(''); - return { - height: h, - body: - '' + - '' + - cells + - '', - }; -} - -// ---------------- main ---------------- +const { renderStyle, ALL_STYLES, escapeXml, fmtNum } = require('./styles.js'); function renderCard(opts) { - const theme = getTheme(opts.theme || 'dark'); - const snap = opts.snapshot; + const style = opts.style || 'compact'; const history = opts.history || []; const prev = history.length > 0 ? history[history.length - 1] : null; - const panels = opts.panels || ['grade', 'scale', 'fragility', 'hidden-costs']; - const repo = opts.repo || ''; - const sha = opts.sha ? opts.sha.slice(0, 7) : ''; - const showPin = opts.pin !== false; - - const innerW = W - PAD * 2; - - const blocks = []; - let cursorY = HEADER_H; - - function add(panel) { - if (!panel) return; - blocks.push(panel.body); - cursorY += panel.height + PANEL_GAP; - } - - for (const p of panels) { - if (p === 'grade') add(panelGrade(snap, prev, theme, PAD, cursorY, innerW)); - else if (p === 'scale') add(panelScale(snap, history.concat([snap]), theme, PAD, cursorY, innerW)); - else if (p === 'fragility') add(panelFragility(snap, theme, PAD, cursorY, innerW)); - else if (p === 'hidden-costs') add(panelHiddenCosts(snap, prev, theme, PAD, cursorY, innerW)); - } - - const totalH = cursorY - PANEL_GAP + FOOTER_H + 10; - - const header = - '' + - '' + escapeXml(repo || 'codeflow card') + '' + - '' + - (sha ? '@' + escapeXml(sha) : '') + '' + - ''; - - const footerY = totalH - 14; - const updated = new Date().toISOString().slice(0, 10); - const footer = - '' + - 'updated ' + updated + '' + - (showPin - ? 'powered by ' + - 'codeflow' - : '') + - ''; - - return ( - '' + - '' + - header + - blocks.join('') + - footer + - '' - ); + return renderStyle(style, Object.assign({}, opts, { prev })); } -module.exports = { renderCard, escapeXml, fmtNum }; +module.exports = { renderCard, escapeXml, fmtNum, ALL_STYLES }; diff --git a/card/render/styles.js b/card/render/styles.js new file mode 100644 index 0000000..a47bfe7 --- /dev/null +++ b/card/render/styles.js @@ -0,0 +1,656 @@ +// Card style variants. Each style is a function that takes the same opts and +// returns an SVG string. Picked by the `style` input on the Action. + +'use strict'; + +const { getTheme } = require('./theme.js'); +const { sparkline } = require('./sparkline.js'); + +const ALL_STYLES = ['compact', 'row', 'minimal', 'hero', 'detailed']; + +function escapeXml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function fmtNum(n) { + if (n == null) return '—'; + if (typeof n !== 'number') return String(n); + if (n >= 1_000_000) return Math.round(n / 100_000) / 10 + 'M'; + if (n >= 1_000) return Math.round(n / 100) / 10 + 'K'; + return String(n); +} + +function gradeColor(theme, grade) { + if (!grade) return theme.textDim; + if (grade.startsWith('A')) return theme.green; + if (grade.startsWith('B')) return theme.green; + if (grade.startsWith('C')) return theme.amber; + if (grade.startsWith('D')) return theme.amber; + return theme.red; +} + +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 ''; + return ''; +} + +function svgWrap(width, height, theme, body, opts) { + const rounded = opts && opts.radius != null ? opts.radius : 12; + return ( + '' + + '' + + body + + '' + ); +} + +const CODEFLOW_URL = 'https://github.com/braedonsaunders/codeflow'; + +function pinFooter(theme, x, y, showPin) { + if (!showPin) return ''; + return ( + '' + + '' + + 'powered by codeflow' + + '' + ); +} + +// ============================================================================ +// Style: compact (default) — grade left, 4 scale stats right, single row +// ============================================================================ + +function renderCompact(opts) { + const theme = getTheme(opts.theme, { accent: opts.accent }); + const snap = opts.snapshot; + const prev = opts.prev; + const repo = opts.repo || ''; + const sha = opts.sha ? opts.sha.slice(0, 7) : ''; + const showGrade = opts.showGrade !== false; + const showScore = opts.showScore !== false && showGrade; + const W = 720; + const H = 140; + const PAD = 18; + + const grade = snap.grade || '?'; + const score = typeof snap.score === 'number' ? snap.score : null; + const color = gradeColor(theme, grade); + const arrow = prev ? gradeArrow(grade, prev.grade, theme) : ''; + const history = (opts.history || []).concat([snap]); + + // When grade is hidden, we show 5 scale stats instead of 4 to fill the space. + const stats = showGrade + ? [ + { label: 'FILES', value: fmtNum(snap.files), key: 'files' }, + { label: 'FNS', value: fmtNum(snap.functions), key: 'functions' }, + { label: 'LOC', value: fmtNum(snap.loc), key: 'loc' }, + { label: 'LANGS', value: snap.languages, key: 'languages' }, + ] + : [ + { label: 'FILES', value: fmtNum(snap.files), key: 'files' }, + { label: 'FNS', value: fmtNum(snap.functions), key: 'functions' }, + { label: 'LOC', value: fmtNum(snap.loc), key: 'loc' }, + { label: 'LANGS', value: snap.languages, key: 'languages' }, + { label: 'TESTS', value: snap.testFiles || 0, key: 'testFiles' }, + ]; + + const gradeBoxW = showGrade ? 130 : 0; + const statsAreaX = PAD + gradeBoxW + (showGrade ? 18 : 0); + const statsAreaW = W - statsAreaX - PAD; + const colW = statsAreaW / stats.length; + + const headerY = 26; + const header = + '' + escapeXml(repo) + '' + + (sha ? '@' + escapeXml(sha) + '' : ''); + + const gradeBlock = showGrade + ? '' + + 'HEALTH' + + '' + escapeXml(grade) + arrow + '' + + (showScore && score != null + ? '' + score + '' + + '/100' + : '') + + '' + : ''; + + const statCells = stats + .map((s, i) => { + const cx = statsAreaX + i * colW; + const series = history.map((r) => r[s.key]).filter((v) => typeof v === 'number'); + const sp = series.length > 1 ? sparkline(series, { width: Math.min(64, colW - 12), height: 14, stroke: theme.spark, fill: theme.sparkBg }) : ''; + return ( + '' + + '' + s.label + '' + + '' + s.value + '' + + (sp ? '' + sp + '' : '') + + '' + ); + }) + .join(''); + + const footer = pinFooter(theme, W - PAD, H - 10, opts.pin !== false); + + return svgWrap(W, H, theme, header + gradeBlock + statCells + footer); +} + +// ============================================================================ +// Style: row — single horizontal status-bar +// ============================================================================ + +function renderRow(opts) { + const theme = getTheme(opts.theme, { accent: opts.accent }); + const snap = opts.snapshot; + const repo = opts.repo || ''; + const showGrade = opts.showGrade !== false; + const showScore = opts.showScore !== false && showGrade; + const W = 720; + const H = 60; + const PAD = 16; + + const grade = snap.grade || '?'; + const score = typeof snap.score === 'number' ? snap.score : null; + const color = gradeColor(theme, grade); + + const gradePill = showGrade + ? '' + + '' + + '' + escapeXml(grade) + '' + + '' + : ''; + + const repoX = showGrade ? PAD + 56 : PAD; + const repoText = + '' + escapeXml(repo) + ''; + + const statsParts = [ + fmtNum(snap.files) + ' files', + fmtNum(snap.functions) + ' fns', + fmtNum(snap.loc) + ' LOC', + snap.languages + ' langs', + ]; + if (snap.testFiles) statsParts.push(snap.testFiles + ' tests'); + if (showScore && score != null) statsParts.push('score ' + score); + const statsText = statsParts.join(' · '); + + const stats = + '' + escapeXml(statsText) + ''; + + const pin = opts.pin !== false + ? '' + + '' + + 'codeflow' + + '' + : ''; + + return svgWrap(W, H, theme, gradePill + repoText + stats + pin, { radius: 8 }); +} + +// ============================================================================ +// Style: minimal — single text line, monospace +// ============================================================================ + +function renderMinimal(opts) { + const theme = getTheme(opts.theme, { accent: opts.accent }); + const snap = opts.snapshot; + const repo = opts.repo || ''; + const showGrade = opts.showGrade !== false; + const W = 720; + const H = 40; + const PAD = 14; + + const grade = snap.grade || '?'; + const color = gradeColor(theme, grade); + + const gradeSpan = showGrade + ? '' + escapeXml(grade) + '' + + ' · ' + : ''; + + const line = + '' + + '' + escapeXml(repo) + '' + + ' · ' + + gradeSpan + + '' + fmtNum(snap.files) + '' + + ' files · ' + + '' + fmtNum(snap.functions) + '' + + ' fns · ' + + '' + fmtNum(snap.loc) + '' + + ' LOC · ' + + '' + snap.languages + '' + + ' langs' + + '' + + (opts.pin !== false + ? '' + + 'codeflow' + + '' + : ''); + + return svgWrap(W, H, theme, line, { radius: 6 }); +} + +// ============================================================================ +// Style: hero — bigger, splashier, gradient +// ============================================================================ + +function renderHero(opts) { + const theme = getTheme(opts.theme, { accent: opts.accent }); + const snap = opts.snapshot; + const prev = opts.prev; + const repo = opts.repo || ''; + const sha = opts.sha ? opts.sha.slice(0, 7) : ''; + const showGrade = opts.showGrade !== false; + const showScore = opts.showScore !== false && showGrade; + const W = 720; + const H = 200; + const PAD = 24; + + const grade = snap.grade || '?'; + const score = typeof snap.score === 'number' ? snap.score : null; + const color = gradeColor(theme, grade); + const arrow = prev ? gradeArrow(grade, prev.grade, theme) : ''; + + const defs = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const header = + '' + + (showGrade ? 'CODEFLOW · HEALTH REPORT' : 'CODEFLOW · CODEBASE OVERVIEW') + '' + + '' + escapeXml(repo) + '' + + (sha ? '@' + escapeXml(sha) + '' : ''); + + const gradeBig = showGrade + ? '' + escapeXml(grade) + arrow + '' + + (showScore && score != null + ? '' + score + '' + + 'SCORE / 100' + : '') + : ''; + + // Grid of stats. With grade hidden we expand to a wider 3x2 grid that takes + // the whole card and keeps it informational. + const grid = showGrade + ? [ + { label: 'FILES', value: fmtNum(snap.files) }, + { label: 'FUNCTIONS', value: fmtNum(snap.functions) }, + { label: 'LINES OF CODE', value: fmtNum(snap.loc) }, + { label: 'LANGUAGES', value: snap.languages }, + ] + : [ + { label: 'FILES', value: fmtNum(snap.files) }, + { label: 'FUNCTIONS', value: fmtNum(snap.functions) }, + { label: 'LINES OF CODE', value: fmtNum(snap.loc) }, + { label: 'LANGUAGES', value: snap.languages }, + { label: 'TEST FILES', value: snap.testFiles || 0 }, + { label: 'CONNECTIONS', value: fmtNum(snap.connections || 0) }, + ]; + const cols = showGrade ? 2 : 3; + const cellW = showGrade ? 140 : 215; + const cellH = 50; + const gridX = showGrade ? W - PAD - cellW * cols : PAD; + const gridY = showGrade ? 90 : 86; + const gridSvg = grid + .map((g, i) => { + const cx = gridX + (i % cols) * cellW; + const cy = gridY + Math.floor(i / cols) * cellH; + return ( + '' + + '' + g.label + '' + + '' + g.value + '' + + '' + ); + }) + .join(''); + + const footer = pinFooter(theme, W - PAD, H - 14, opts.pin !== false); + + return svgWrap(W, H, theme, defs + header + gradeBig + gridSvg + footer, { radius: 16 }); +} + +// ============================================================================ +// Style: detailed — original 4-panel renderer +// ============================================================================ + +function panelGradeDetailed(snap, prev, theme, x, y, width, opts) { + const showScore = opts && opts.showScore !== false; + const grade = snap.grade || '?'; + const score = typeof snap.score === 'number' ? snap.score : null; + const color = gradeColor(theme, grade); + const arrow = prev ? gradeArrow(grade, prev.grade, theme) : ''; + const prevLabel = prev && prev.grade && prev.grade !== grade ? '(was ' + escapeXml(prev.grade) + ')' : ''; + const h = 110; + return { + height: h, + body: + '' + + '' + + 'HEALTH' + + '' + escapeXml(grade) + arrow + '' + + (showScore && score != null + ? '' + score + '' + + 'SCORE / 100' + : '') + + (prevLabel ? '' + prevLabel + '' : '') + + '', + }; +} + +function panelLanguagesDetailed(snap, theme, x, y, width) { + const list = Array.isArray(snap.topLanguages) ? snap.topLanguages.slice(0, 5) : []; + const headerH = 28; + const rowH = 22; + const h = headerH + Math.max(rowH * Math.min(list.length, 5), rowH) + 14; + const palette = [theme.accent, theme.spark, theme.green, theme.amber, theme.textDim]; + + let rows; + if (list.length === 0) { + rows = 'No language stats.'; + } else { + rows = list + .map((lang, i) => { + const ry = headerH + 6 + i * rowH; + const label = '.' + (lang.ext || '?'); + const pct = typeof lang.pct === 'number' ? lang.pct : 0; + const barW = Math.max(2, Math.round((pct / 100) * (width - 220))); + const barX = 80; + const color = palette[i % palette.length]; + return ( + '' + escapeXml(label) + '' + + '' + + '' + + '' + pct + '%' + ); + }) + .join(''); + } + return { + height: h, + body: + '' + + '' + + 'LANGUAGES' + + rows + + '', + }; +} + +function panelComposition(snap, theme, x, y, width) { + const items = [ + { label: 'CONNECTIONS', value: fmtNum(snap.connections || 0) }, + { label: 'TEST FILES', value: snap.testFiles || 0 }, + { label: 'FOLDERS', value: snap.folders || 0 }, + { label: 'AVG FN LINES', value: snap.avgFnLines || 0 }, + { label: 'LONGEST FN', value: snap.longestFn ? snap.longestFn + 'L' : '—' }, + { label: 'PATTERNS', value: snap.patterns || 0 }, + ]; + const h = 88; + const cols = 3; + const colW = (width - 32) / cols; + const cells = items + .map((item, i) => { + const cx = 16 + (i % cols) * colW; + const cy = 22 + Math.floor(i / cols) * 32; + return ( + '' + + '' + item.label + '' + + '' + escapeXml(item.value) + '' + + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + cells + + '', + }; +} + +function panelTopFolders(snap, theme, x, y, width) { + const list = Array.isArray(snap.topFolders) ? snap.topFolders.slice(0, 5) : []; + const headerH = 28; + const rowH = 18; + const h = headerH + Math.max(rowH * Math.min(list.length, 5), rowH) + 12; + if (list.length === 0) { + return { + height: h, + body: + '' + + '' + + 'TOP FOLDERS' + + 'No folders found.' + + '', + }; + } + const max = list[0].count; + const rows = list + .map((f, i) => { + const ry = headerH + 6 + i * rowH; + const barW = Math.max(2, Math.round((f.count / max) * (width - 220))); + return ( + '' + escapeXml(f.name || 'root') + '' + + '' + + '' + + '' + f.count + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + 'TOP FOLDERS · BY FILE COUNT' + + rows + + '', + }; +} + +function panelScaleDetailed(snap, history, theme, x, y, width) { + const items = [ + { label: 'FILES', value: snap.files, key: 'files' }, + { label: 'FNS', value: snap.functions, key: 'functions' }, + { label: 'LOC', value: snap.loc, key: 'loc' }, + { label: 'LANGS', value: snap.languages, key: 'languages' }, + ]; + const h = 88; + const colW = (width - 32) / items.length; + const cells = items + .map((item, i) => { + const cx = 16 + i * colW; + const series = history.map((r) => r[item.key]).filter((v) => typeof v === 'number'); + const spark = series.length > 1 ? sparkline(series, { width: Math.min(80, colW - 16), height: 18, stroke: theme.spark, fill: theme.sparkBg }) : ''; + return ( + '' + + '' + item.label + '' + + '' + fmtNum(item.value) + '' + + (spark ? '' + spark + '' : '') + + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + cells + + '', + }; +} + +function panelFragilityDetailed(snap, theme, x, y, width) { + const list = Array.isArray(snap.fragility) ? snap.fragility.slice(0, 3) : []; + const rowH = 18; + const headerH = 28; + const h = headerH + Math.max(rowH * 3, rowH * Math.max(list.length, 1)) + 12; + let rows = ''; + if (list.length === 0) { + rows = 'No cross-file dependencies detected.'; + } else { + rows = list + .map((f, i) => { + const ry = headerH + 6 + i * rowH; + const name = f.path.length > 48 ? '…' + f.path.slice(-47) : f.path; + return ( + '' + escapeXml(name) + '' + + '' + f.direct + ' direct · ' + f.transitive + ' transitive' + ); + }) + .join(''); + } + return { + height: h, + body: + '' + + '' + + 'FRAGILITY · TOP BLAST RADIUS' + + rows + + '', + }; +} + +function panelHiddenCostsDetailed(snap, prev, theme, x, y, width) { + const items = [ + { label: 'CIRCULAR DEPS', value: snap.circular, prev: prev ? prev.circular : null, lowerIsBetter: true }, + { label: 'DEAD CODE', value: snap.deadPct + '%', raw: snap.deadPct, prev: prev ? prev.deadPct : null, lowerIsBetter: true }, + { label: 'AVG COUPLING', value: snap.avgCoupling, prev: prev ? prev.avgCoupling : null, lowerIsBetter: true }, + ]; + const h = 70; + const colW = (width - 32) / items.length; + const cells = items + .map((item, i) => { + const cx = 16 + i * colW; + let arrow = ''; + const curr = typeof item.raw === 'number' ? item.raw : item.value; + if (item.prev != null && typeof curr === 'number' && curr !== item.prev) { + const better = item.lowerIsBetter ? curr < item.prev : curr > item.prev; + const sign = curr < item.prev ? '▼' : '▲'; + const color = better ? theme.green : theme.red; + arrow = '' + sign + ''; + } + return ( + '' + + '' + item.label + '' + + '' + escapeXml(item.value) + arrow + '' + + '' + ); + }) + .join(''); + return { + height: h, + body: + '' + + '' + + cells + + '', + }; +} + +function renderDetailed(opts) { + const theme = getTheme(opts.theme, { accent: opts.accent }); + const snap = opts.snapshot; + const history = opts.history || []; + const prev = opts.prev; + const showGrade = opts.showGrade !== false; + const showScore = opts.showScore !== false && showGrade; + // Detailed always renders the full informational set unless `panels` was + // overridden explicitly. The default is everything. Grade panel auto-drops + // when `show-grade: false`. + const DEFAULT_PANELS = ['grade', 'scale', 'languages', 'composition', 'top-folders', 'fragility', 'hidden-costs']; + const requested = (opts.panels && opts.panels.length > 0) ? opts.panels : DEFAULT_PANELS; + const panels = showGrade ? requested : requested.filter((p) => p !== 'grade' && p !== 'hidden-costs'); + const repo = opts.repo || ''; + const sha = opts.sha ? opts.sha.slice(0, 7) : ''; + const showPin = opts.pin !== false; + + const W = 720; + const PAD = 22; + const HEADER_H = 60; + const PANEL_GAP = 14; + const FOOTER_H = 36; + const innerW = W - PAD * 2; + + const blocks = []; + let cursorY = HEADER_H; + + for (const p of panels) { + let panel = null; + if (p === 'grade') panel = panelGradeDetailed(snap, prev, theme, PAD, cursorY, innerW, { showScore }); + else if (p === 'scale') panel = panelScaleDetailed(snap, history.concat([snap]), theme, PAD, cursorY, innerW); + else if (p === 'languages') panel = panelLanguagesDetailed(snap, theme, PAD, cursorY, innerW); + else if (p === 'composition') panel = panelComposition(snap, theme, PAD, cursorY, innerW); + else if (p === 'top-folders') panel = panelTopFolders(snap, theme, PAD, cursorY, innerW); + else if (p === 'fragility') panel = panelFragilityDetailed(snap, theme, PAD, cursorY, innerW); + else if (p === 'hidden-costs') panel = panelHiddenCostsDetailed(snap, prev, theme, PAD, cursorY, innerW); + if (!panel) continue; + blocks.push(panel.body); + cursorY += panel.height + PANEL_GAP; + } + + const totalH = cursorY - PANEL_GAP + FOOTER_H + 10; + const header = + '' + + '' + escapeXml(repo) + '' + + '' + + (sha ? '@' + escapeXml(sha) : '') + '' + + ''; + + const footerY = totalH - 14; + const updated = new Date().toISOString().slice(0, 10); + const footer = + '' + + 'updated ' + updated + '' + + (showPin + ? '' + + 'powered by ' + + 'codeflow' + + '' + : '') + + ''; + + return svgWrap(W, totalH, theme, header + blocks.join('') + footer, { radius: 14 }); +} + +// ============================================================================ +// Dispatcher +// ============================================================================ + +const RENDERERS = { + compact: renderCompact, + row: renderRow, + minimal: renderMinimal, + hero: renderHero, + detailed: renderDetailed, +}; + +function renderStyle(style, opts) { + const r = RENDERERS[style] || RENDERERS.compact; + return r(opts); +} + +module.exports = { renderStyle, ALL_STYLES, escapeXml, fmtNum }; diff --git a/card/render/theme.js b/card/render/theme.js index 0e4606a..6ed5212 100644 --- a/card/render/theme.js +++ b/card/render/theme.js @@ -35,9 +35,52 @@ const LIGHT = { sparkBg: 'rgba(111,66,193,0.12)', }; -function getTheme(name) { - if (name === 'light') return LIGHT; - return DARK; +// Named accent presets. Users can also pass any hex/CSS color directly. +const ACCENT_PRESETS = { + purple: { dark: '#a78bfa', light: '#6f42c1' }, + teal: { dark: '#5eead4', light: '#0d9488' }, + cyan: { dark: '#67e8f9', light: '#0891b2' }, + green: { dark: '#86efac', light: '#16a34a' }, + pink: { dark: '#f9a8d4', light: '#db2777' }, + blue: { dark: '#93c5fd', light: '#2563eb' }, + amber: { dark: '#fcd34d', light: '#d97706' }, + red: { dark: '#fca5a5', light: '#dc2626' }, +}; + +function withAlpha(hex, alpha) { + // Takes a #RRGGBB hex and returns rgba(r,g,b,a). Supports #RGB shorthand. + const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex); + if (!m) return hex; + let r, g, b; + if (m[1].length === 3) { + r = parseInt(m[1][0] + m[1][0], 16); + g = parseInt(m[1][1] + m[1][1], 16); + b = parseInt(m[1][2] + m[1][2], 16); + } else { + r = parseInt(m[1].slice(0, 2), 16); + g = parseInt(m[1].slice(2, 4), 16); + b = parseInt(m[1].slice(4, 6), 16); + } + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; +} + +function resolveAccent(value, mode) { + if (!value) return null; + const preset = ACCENT_PRESETS[String(value).toLowerCase()]; + if (preset) return preset[mode] || preset.dark; + return value; // assume CSS color +} + +function getTheme(name, opts) { + const base = name === 'light' ? Object.assign({}, LIGHT) : Object.assign({}, DARK); + const accent = opts && opts.accent ? resolveAccent(opts.accent, name === 'light' ? 'light' : 'dark') : null; + if (accent) { + base.accent = accent; + base.accentSoft = withAlpha(accent, name === 'light' ? 0.12 : 0.16); + base.spark = accent; + base.sparkBg = withAlpha(accent, name === 'light' ? 0.12 : 0.18); + } + return base; } -module.exports = { getTheme, DARK, LIGHT }; +module.exports = { getTheme, DARK, LIGHT, ACCENT_PRESETS };