diff --git a/README.md b/README.md index ffd22f5..167085b 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. @@ -347,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/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..b386dec --- /dev/null +++ b/card/action.yml @@ -0,0 +1,70 @@ +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' + 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: '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: '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 + 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/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 new file mode 100644 index 0000000..bc404b7 --- /dev/null +++ b/card/index.js @@ -0,0 +1,161 @@ +// 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, + accent: inputs.accent, + style: inputs.style, + panels: inputs.panels, + showGrade: inputs.showGrade, + showScore: inputs.showScore, + 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..4ac4338 --- /dev/null +++ b/card/lib/inputs.js @@ -0,0 +1,72 @@ +// 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 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); + 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, + accent, + style, + panels, + showGrade, + showScore, + 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..1d2be65 --- /dev/null +++ b/card/lib/state.js @@ -0,0 +1,144 @@ +// 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); + + // 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 { + 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, 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, + 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..e734d94 --- /dev/null +++ b/card/render/card.js @@ -0,0 +1,15 @@ +// Card entry point. Dispatches to the requested style; see styles.js for +// individual renderers. + +'use strict'; + +const { renderStyle, ALL_STYLES, escapeXml, fmtNum } = require('./styles.js'); + +function renderCard(opts) { + const style = opts.style || 'compact'; + const history = opts.history || []; + const prev = history.length > 0 ? history[history.length - 1] : null; + return renderStyle(style, Object.assign({}, opts, { prev })); +} + +module.exports = { renderCard, escapeXml, fmtNum, ALL_STYLES }; 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/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 new file mode 100644 index 0000000..6ed5212 --- /dev/null +++ b/card/render/theme.js @@ -0,0 +1,86 @@ +// 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)', +}; + +// 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, ACCENT_PRESETS }; 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');