Prerelease cleanup (#46)

* feat: Add const_bound_vars tracking to prevent false positives in ownership checks

* feat: Introduce field interner and typed bounded vars for enhanced type tracking

* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking

* feat: Centralize method name extraction with bare_method_name helper

* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch

* feat: Enhance C++ taint tracking with additional container operations and inline method resolution

* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking

* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis

* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations

* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details

* test: Add comprehensive tests for lattice algebra laws and SSA edge cases

* feat: Add destructured session user handling and safe user ID access patterns

* feat: Implement row-population reverse-walk for enhanced authorization checks

* feat: Enhance authorization checks with local alias chain for self-actor types

* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction

* feat: Implement chained method call inner-gate rebinding for SSRF prevention

* feat: Add observability and error modules, enhance debug functionality, and implement theme context

* feat: Remove Auth Analysis page and update navigation to redirect to Explorer

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor

* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity

* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build

The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(closure-capture): flip JS/TS fixtures to required-finding

The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.

Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".

Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis

* feat: Introduce health module and enhance health score computation with calibration tests

* feat: Add expectations configuration and cleanup .gitignore for log files

* feat: Implement theme selection and enhance settings panel for triage sync

* feat: Suppress false positives for strcpy calls with literal sources in AST

* feat: Update analyse_function_ssa to return body CFG for accurate analysis

* feat: Add bug report and feature request templates for improved issue tracking

* feat: removed dev scripts

* feat: update README.md for clarity and consistency in fixture descriptions

* feat: removed dev docs

* feat: clean up error handling and UI elements for improved user experience

* feat: adjust button sizes in HeaderBar for better UI consistency

* feat: enhance taint analysis with additional context for sanitizer and taint findings

* cargo fmt

* prettier

* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts

* feat: add script to frame PNG screenshots with brand gradient

* feat: add fuzzing support with new targets and CI workflows

* refactor: streamline match expressions and improve formatting in CLI and output handling

* feat: enhance configuration display with detailed output options

* feat: stage demo configuration for improved CLI screenshot output

* feat: expose merge_configs function for user-configurable settings

* refactor: simplify code structure and improve readability in config handling

* refactor: improve descriptions for vulnerability patterns in various languages

* feat: update MIT License section with additional usage details and copyright information

* feat: update screenshots

* refactor: update build process and paths for frontend assets

* feat: add cross-file taint fuzzing target and supporting dictionary

* refactor: clean up formatting and comments in fuzz configuration and example files

* refactor: remove outdated comments and clean up CI configuration files

* chore: update changelog dates and improve formatting in documentation

* refactor: update Cargo.toml and CI configuration for improved packaging and build process

* refactor: enhance quote-stripping logic to prevent panics and add regression tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -1,11 +1,14 @@
# scripts
Local helpers to run repo-wide tasks without hopping between `./` and `./frontend`.
Local helpers for repo-wide checks and a couple of one-off tools.
| Script | What it does |
| ------------ | ----------------------------------------------------------------------- |
| `fix.sh` | Apply all auto-fixes (clippy, fmt, eslint, prettier), then run tests. |
| `check.sh` | Verify only (no fixes). Mirrors the GitHub Actions CI workflow. |
| Script | What it does |
| ------------------------ | --------------------------------------------------------------------------------------------- |
| `fix.sh` | Apply all auto-fixes (clippy, fmt, eslint, prettier), then run tests. |
| `check.sh` | Verify only (no fixes). Mirrors the GitHub Actions CI workflow. |
| `cached-cargo-test.sh` | Wrap `cargo test` with a source-hash cache; concurrent invocations of the same args share one run. |
| `capture-screenshots.mjs`| Capture the README stills and demo GIF from a running `nyx serve`. Needs Playwright and ffmpeg. |
| `frame-screenshots.py` | Wrap a PNG in the brand purple gradient. Called by `capture-screenshots.mjs` as its final phase, but can be run standalone. |
Fixers stream their output (so you can see what changed); tests run quietly and
only show output if they fail. Both scripts print a green/red summary at the end
@ -25,3 +28,73 @@ and exit non-zero if any step failed.
Scripts can be run from any directory; they resolve the repo root from their
own location.
## Cached cargo test
Wraps `cargo test`. The first run executes normally and records its output
keyed by a hash of the source tree. Later runs with the same args and an
unchanged tree return the cached output. Concurrent callers share a single
cargo run via a mkdir lock.
```bash
./scripts/cached-cargo-test.sh --lib
./scripts/cached-cargo-test.sh --tests
FORCE_CARGO=1 ./scripts/cached-cargo-test.sh --lib # bypass cache
```
Use it for full-suite invocations. Narrow per-test runs (`cargo test
some_function`) are fast on their own and just clutter the cache.
## Capture screenshots
Regenerates `assets/screenshots/*.png` and `assets/screenshots/demo.gif` for
the README and `docs/`. Requires Playwright, ffmpeg, and Python 3 with
Pillow on PATH, plus a running `nyx serve` on `$NYX_URL` (default
`http://127.0.0.1:9876`). The served scan root must have no prior scans.
```bash
node scripts/capture-screenshots.mjs --stills # only PNGs
node scripts/capture-screenshots.mjs --gif # only the GIF
node scripts/capture-screenshots.mjs --all # both
```
The script writes a synthetic demo to `$SCAN_ROOT` (default
`/tmp/nyx-demo-app`). V1 has four endpoints and produces a 5-hop CMDi
taint flow that the GIF drills into. After scan #1 the script overwrites
the demo with V2 (just that one flow) and runs scan #2 via the API, so
the overview trend chart shows findings going down.
Stills are captured in two phases:
- After scan #1 (more findings): `serve-findings-list.png`,
`serve-finding-detail.png`.
- After scan #2 (trend established): `serve-overview.png`,
`serve-triage.png`, `serve-explorer.png`, `serve-scans.png`,
`serve-scan-detail.png`, `serve-rules.png`, `serve-config.png`.
Then `frame-screenshots.py` runs over every captured PNG and wraps it in
the brand purple gradient (1800x1113 outer, 1600x992 inner, 12px rounded
corners, top-left `#8a5bf5` to bottom-right `#4d1d97`). Finally,
`docs/serve-overview.png` is copied to the top-level `overview.png`
because that is the path the README references.
GIF storyboard:
1. Empty dashboard with the "Run your first scan" prompt.
2. Click `Start Scan` in the header bar to open the modal.
3. Confirm in the modal and wait for the scan to finish.
4. Back to the overview, scroll down through the cards, scroll back.
5. Click `Findings` in the sidebar.
6. Click the 5-hop taint row.
7. On the finding detail, expand Evidence, Analysis Notes, and
Confidence Reasoning.
8. Open the triage status dropdown and dismiss it.
9. Navigate to `/debug/call-graph` for the closing visual.
To frame an existing PNG without re-capturing:
```bash
python3 scripts/frame-screenshots.py path/to/foo.png [...]
```
Run with no args to re-frame every PNG under `assets/screenshots/`.

149
scripts/cached-cargo-test.sh Executable file
View file

@ -0,0 +1,149 @@
#!/bin/bash
# Cached cargo test wrapper.
#
# Returns the cached output of a prior identical `cargo test` run when
# the source tree hasn't changed. Concurrent invocations with the same
# cache key serialize via a mkdir-based lock — only one cargo run
# actually executes; later callers wait, then return the cached result
# instantly.
#
# Usage:
# scripts/cached-cargo-test.sh [cargo-test-args...]
#
# Bypass:
# FORCE_CARGO=1 scripts/cached-cargo-test.sh ...
#
# When to use: full-suite invocations like
# scripts/cached-cargo-test.sh --lib
# scripts/cached-cargo-test.sh --tests
# scripts/cached-cargo-test.sh --test benchmark_test benchmark_evaluation -- --ignored --nocapture
#
# When NOT to use: narrow per-test runs like
# cargo test --test integration_tests rust_web_app
# cargo test some_function_name
# Those are fast on their own and would just clutter the cache.
set -uo pipefail
NYX_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CACHE_DIR="${NYX_CARGO_CACHE_DIR:-/tmp/nyx-cargo-cache}"
LOCK_TIMEOUT_SECS=7200 # 2h max wait for a concurrent leader
POLL_INTERVAL_SECS=1
mkdir -p "$CACHE_DIR"
cd "$NYX_DIR"
# ---- portable sha256 ----
sha256_cmd() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
else
# macOS ships `shasum`; -a 256 selects sha256 and outputs the same
# `<hash> <file>` format as sha256sum.
shasum -a 256 "$@"
fi
}
# ---- compute cache key ----
# Hash everything that could affect cargo-test outcomes. Filename of each
# input is included in the per-file sha256 line, so renames + additions +
# deletions all change the rolled-up hash. The [ -f ] filter drops
# deleted-but-still-indexed files so we don't error out on them.
compute_source_hash() {
{
git ls-files src tests benches 2>/dev/null
git ls-files --others --exclude-standard src tests benches 2>/dev/null
for f in Cargo.toml Cargo.lock build.rs rust-toolchain rust-toolchain.toml; do
[ -f "$f" ] && echo "$f"
done
} | sort -u | while IFS= read -r f; do
[ -f "$f" ] && sha256_cmd "$f"
done | sha256_cmd | awk '{print $1}'
}
# Hash the args verbatim. -separating with NUL bytes so "--lib" and
# "--li b" hash differently.
compute_args_hash() {
if [ "$#" -eq 0 ]; then
printf '' | sha256_cmd | awk '{print $1}'
else
printf '%s\0' "$@" | sha256_cmd | awk '{print $1}'
fi
}
# Hash env vars that can change build/test outcomes.
compute_env_hash() {
env | grep -E '^(RUST|CARGO|NYX)_' | LC_ALL=C sort \
| sha256_cmd | awk '{print $1}'
}
SOURCE_HASH=$(compute_source_hash)
ARGS_HASH=$(compute_args_hash "$@")
ENV_HASH=$(compute_env_hash)
KEY="${SOURCE_HASH:0:16}-${ARGS_HASH:0:8}-${ENV_HASH:0:8}"
LOG_FILE="$CACHE_DIR/$KEY.log"
RC_FILE="$CACHE_DIR/$KEY.rc"
LOCK_DIR="$CACHE_DIR/$KEY.lock.d"
# ---- bypass ----
if [ "${FORCE_CARGO:-0}" != "0" ]; then
echo "[cached-cargo-test] FORCE_CARGO=1 — bypassing cache" >&2
exec cargo test "$@"
fi
# ---- fast path: cache hit, no lock needed ----
if [ -f "$LOG_FILE" ] && [ -f "$RC_FILE" ]; then
RC=$(cat "$RC_FILE")
echo "[cached-cargo-test] cache hit (key $KEY, rc $RC) — source unchanged since prior run" >&2
cat "$LOG_FILE"
exit "$RC"
fi
# ---- slow path: acquire lock, double-check, run if leader ----
attempts=0
while true; do
if mkdir "$LOCK_DIR" 2>/dev/null; then
echo "$$" > "$LOCK_DIR/pid"
break
fi
# Stale-lock detection
if [ -f "$LOCK_DIR/pid" ]; then
OLD_PID=$(cat "$LOCK_DIR/pid" 2>/dev/null || echo "")
if [ -n "$OLD_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null; then
echo "[cached-cargo-test] reaping stale lock from dead pid $OLD_PID" >&2
rm -rf "$LOCK_DIR" 2>/dev/null
continue
fi
fi
if [ "$attempts" -eq 0 ]; then
echo "[cached-cargo-test] another invocation is running this same test set; waiting..." >&2
fi
attempts=$((attempts + 1))
if [ "$attempts" -gt "$LOCK_TIMEOUT_SECS" ]; then
echo "[cached-cargo-test] gave up waiting for lock after ${LOCK_TIMEOUT_SECS}s" >&2
exit 1
fi
sleep "$POLL_INTERVAL_SECS"
done
# Always release the lock on exit, even on failure
trap 'rm -rf "$LOCK_DIR" 2>/dev/null' EXIT
# Double-check: the leader may have populated the cache while we waited.
if [ -f "$LOG_FILE" ] && [ -f "$RC_FILE" ]; then
RC=$(cat "$RC_FILE")
echo "[cached-cargo-test] cache hit after waiting (concurrent leader populated cache, rc $RC)" >&2
cat "$LOG_FILE"
exit "$RC"
fi
# We're the leader — actually run cargo.
echo "[cached-cargo-test] cache miss (key $KEY) — running cargo test $*" >&2
cargo test "$@" 2>&1 | tee "$LOG_FILE"
RC="${PIPESTATUS[0]}"
echo "$RC" > "$RC_FILE"
exit "$RC"

View file

@ -0,0 +1,643 @@
#!/usr/bin/env node
/**
* Capture stills + a demo GIF of the Nyx dashboard for the README/docs.
*
* The demo source is embedded below (V1_SERVER + V2_SERVER) so the
* storyboard is reproducible from this file alone. V1 has 4 endpoints
* and yields ~6 findings (one of them a 5-hop CMDi taint flow that
* the GIF drills into); V2 keeps only that flow so scan #2 has fewer
* findings than scan #1 and the overview trend chart shows a
* downward slope.
*
* Phases:
* 1. setup write V1 to SCAN_ROOT, ensure server reachable
* 2. gif (opt) record the storyboard against a fresh DB; this
* also drives scan #1 via the UI
* 3. scan #1 if --gif didn't run, kick off scan #1 via API
* 4. stills/p1 capture pages whose content benefits from many
* findings (findings list, finding detail)
* 5. patch+scan2 overwrite SCAN_ROOT with V2 + run scan #2 via API
* 6. stills/p2 capture pages whose content benefits from a
* two-scan history (overview trend, scans list,
* scan detail) plus the static-ish ones
* (triage, explorer, rules, config)
* 7. frame composite the brand purple gradient around every
* captured PNG via scripts/frame-screenshots.py
*
* Prerequisites (script asserts each before starting):
* - playwright installed (npx playwright)
* - ffmpeg on PATH (palette-based GIF conversion)
* - python3 + Pillow on PATH (frame compositing)
* - nyx serve running on $NYX_URL (default http://127.0.0.1:9876)
* - the served scan root is empty of prior scans (system DB wiped)
*
* Usage:
* node scripts/capture-screenshots.mjs --stills # PNGs only
* node scripts/capture-screenshots.mjs --gif # GIF only
* node scripts/capture-screenshots.mjs --all # both, in one orchestrated run
*
* Output (under assets/screenshots/):
* demo.gif (~2530s walkthrough)
* overview.png (mirror of docs/serve-overview.png; used by README)
* docs/serve-overview.png (overview after scan #2 trend going down)
* docs/serve-findings-list.png (post-scan-#1 list with multiple highs)
* docs/serve-finding-detail.png (5-hop taint flow visualizer)
* docs/serve-triage.png
* docs/serve-explorer.png
* docs/serve-scans.png
* docs/serve-scan-detail.png
* docs/serve-rules.png
* docs/serve-config.png
*/
import { execFileSync } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdirSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import process from 'node:process';
const URL_BASE = process.env.NYX_URL || 'http://127.0.0.1:9876';
const SCAN_ROOT = process.env.SCAN_ROOT || '/tmp/nyx-demo-app';
const OUT_DIR = process.env.OUT_DIR || '/Users/elipeter/nyx/assets/screenshots';
const FRAMER = process.env.FRAMER || '/Users/elipeter/nyx/scripts/frame-screenshots.py';
const NYX_BIN = process.env.NYX_BIN || '/Users/elipeter/nyx/target/release/nyx';
const VIEW = { width: 1440, height: 900 };
const COLOR_SCHEME = 'light';
const args = new Set(process.argv.slice(2));
const wantStills = args.has('--stills') || args.has('--all');
const wantGif = args.has('--gif') || args.has('--all');
const wantCli = args.has('--cli') || args.has('--all');
if (!wantStills && !wantGif && !wantCli) {
console.error('usage: capture-screenshots.mjs [--stills|--gif|--cli|--all]');
process.exit(2);
}
mkdirSync(join(OUT_DIR, 'docs'), { recursive: true });
// Demo source ----------------------------------------------------------------
const V1_SERVER = `import express from 'express';
import { exec } from 'child_process';
import fs from 'fs';
const app = express();
app.use(express.json());
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
app.get('/lookup/:user', (req, res) => {
const raw = req.params.user;
const cleaned = raw.trim();
const flag = \`--user=\${cleaned}\`;
const cmd = \`whois \${flag} --verbose\`;
exec(cmd, (err, stdout) => {
res.send(stdout);
});
});
// SSRF: req.query.url → fetch.
app.get('/proxy', async (req, res) => {
const target = req.query.url;
const response = await fetch(target);
const body = await response.text();
res.send(body);
});
// Path traversal / unsafe file read.
app.get('/file', (req, res) => {
const requested = req.query.path;
const body = fs.readFileSync(requested, 'utf8');
res.send(body);
});
// Login endpoint with weak (Math.random) session id.
app.post('/login', (req, res) => {
const sid = Math.random().toString(36).slice(2);
res.cookie('sid', sid).json({ ok: true });
});
app.listen(3000);
`;
const V2_SERVER = `import express from 'express';
import { exec } from 'child_process';
const app = express();
app.use(express.json());
// Lookup endpoint. Multi-hop CMDi: req.params.user → trim → flag → cmd → exec.
app.get('/lookup/:user', (req, res) => {
const raw = req.params.user;
const cleaned = raw.trim();
const flag = \`--user=\${cleaned}\`;
const cmd = \`whois \${flag} --verbose\`;
exec(cmd, (err, stdout) => {
res.send(stdout);
});
});
app.listen(3000);
`;
const PACKAGE_JSON = `{ "name": "nyx-demo-app", "version": "1.0.0", "type": "module", "main": "src/server.js" }
`;
const AUTH_JS = `import jwt from 'jsonwebtoken';
const SECRET = 'super-secret-key';
export function sign(p) { return jwt.sign(p, SECRET); }
export function verify(t) { return jwt.verify(t, SECRET); }
`;
function writeDemo(variant) {
mkdirSync(join(SCAN_ROOT, 'src'), { recursive: true });
writeFileSync(join(SCAN_ROOT, 'package.json'), PACKAGE_JSON);
writeFileSync(
join(SCAN_ROOT, 'src/server.js'),
variant === 'v2' ? V2_SERVER : V1_SERVER,
);
const authPath = join(SCAN_ROOT, 'src/auth.js');
if (variant === 'v1') writeFileSync(authPath, AUTH_JS);
if (variant === 'v2' && existsSync(authPath)) unlinkSync(authPath);
}
// Server helpers -------------------------------------------------------------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchJson(path) {
const res = await fetch(URL_BASE + path);
if (!res.ok) throw new Error(`${path}: ${res.status}`);
return res.json();
}
async function csrfToken() {
const r = await fetch(URL_BASE + '/api/session');
return (await r.json()).csrf_token;
}
async function waitForServer() {
for (let i = 0; i < 30; i++) {
try { await fetchJson('/api/health'); return; } catch { await sleep(250); }
}
throw new Error(`nyx serve not reachable at ${URL_BASE}, start it first`);
}
async function startScanViaApi(token) {
const res = await fetch(URL_BASE + '/api/scans', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-nyx-csrf': token },
body: '{}',
});
if (!res.ok && res.status !== 409) {
throw new Error(`POST /api/scans: ${res.status} ${await res.text()}`);
}
}
async function waitForScanComplete(prevScanId, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const overview = await fetchJson('/api/overview').catch(() => null);
if (
overview?.latest_scan_id &&
overview.state !== 'empty' &&
overview.latest_scan_id !== prevScanId
) {
await sleep(400);
return overview;
}
await sleep(400);
}
throw new Error('scan did not complete within deadline');
}
async function currentScanId() {
const overview = await fetchJson('/api/overview').catch(() => null);
return overview?.latest_scan_id ?? null;
}
// Storyboard helpers ---------------------------------------------------------
async function findFirstTaintRow(page) {
return page.locator('tbody tr').filter({ hasText: 'taint-' }).first();
}
// Stills ---------------------------------------------------------------------
async function captureStillsAfterScan1(page) {
console.error('[stills/p1] findings list');
await page.goto(URL_BASE + '/findings');
await page.waitForSelector('tbody tr', { timeout: 15_000 });
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-findings-list.png') });
console.error('[stills/p1] finding detail (5-hop CMDi)');
const row = await findFirstTaintRow(page);
await row.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2200); // flow visualizer animation
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-finding-detail.png') });
}
async function captureStillsAfterScan2(page) {
console.error('[stills/p2] overview (with 2-scan trend)');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-overview.png') });
console.error('[stills/p2] triage');
await page.goto(URL_BASE + '/triage');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-triage.png') });
console.error('[stills/p2] explorer');
await page.goto(URL_BASE + '/explorer');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await page.waitForSelector('.tree-node', { timeout: 10_000 }).catch(() => {});
await sleep(1000);
// Expand the src/ folder, then click server.js so the screenshot
// includes the highlighted source view rather than a "Select a
// file" prompt. Best-effort: skip silently if selectors miss.
const srcDir = page.locator('.tree-node:has-text("src")').first();
if (await srcDir.count()) {
await srcDir.click().catch(() => {});
await sleep(700);
}
const serverFile = page.locator('.tree-node:has-text("server.js")').first();
if (await serverFile.count()) {
await serverFile.click().catch(() => {});
await sleep(1500);
}
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-explorer.png') });
console.error('[stills/p2] scans list');
await page.goto(URL_BASE + '/scans');
await page.waitForSelector('tbody tr', { timeout: 10_000 }).catch(() => {});
await sleep(1500);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scans.png') });
console.error('[stills/p2] scan detail');
const firstScan = page.locator('tbody tr').first();
if (await firstScan.count()) {
await firstScan.click();
await page.waitForURL(/\/scans\/\d+/, { timeout: 10_000 }).catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-scan-detail.png') });
}
console.error('[stills/p2] rules');
await page.goto(URL_BASE + '/rules');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-rules.png') });
console.error('[stills/p2] config');
await page.goto(URL_BASE + '/config');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1800);
await page.screenshot({ path: join(OUT_DIR, 'docs/serve-config.png') });
}
// GIF storyboard -------------------------------------------------------------
async function captureGifFrames(page) {
console.error('[gif] scene 1: empty dashboard');
await page.goto(URL_BASE + '/');
await page.waitForSelector('text=Run your first scan');
await sleep(2200);
console.error('[gif] scene 2: open Start Scan modal');
await page.click('header button:has-text("Start Scan"), .header button:has-text("Start Scan"), button:has-text("Start Scan")');
await page.waitForSelector('.scan-modal');
await sleep(1200);
console.error('[gif] scene 3: confirm scan');
await page.click('.scan-modal button.btn-primary');
await page.waitForURL('**/scans', { timeout: 10_000 }).catch(() => {});
await waitForScanComplete(null);
console.error('[gif] scene 4: back to overview, scroll');
await page.goto(URL_BASE + '/');
await page
.waitForSelector('.health-score-card, [class*="health"]', { timeout: 10_000 })
.catch(() => {});
await sleep(1800);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1500);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(800);
console.error('[gif] scene 5: navigate to Findings');
await page.click('a.nav-link:has-text("Findings"), .sidebar a:has-text("Findings")');
await page.waitForURL('**/findings', { timeout: 10_000 });
await page.waitForSelector('tbody tr', { timeout: 10_000 });
await sleep(1500);
console.error('[gif] scene 6: click the 5-hop taint finding');
const taintRow = await findFirstTaintRow(page);
await taintRow.click();
await page.waitForURL(/\/findings\/\d+/, { timeout: 10_000 });
await sleep(2500);
// Scroll well into the page so the viewer can see the taint flow
// animate before the section toggles fire.
await page.evaluate(() => window.scrollBy({ top: 480, behavior: 'smooth' }));
await sleep(1600);
await page.evaluate(() => window.scrollBy({ top: 360, behavior: 'smooth' }));
await sleep(1600);
console.error('[gif] scene 7: open the collapsed sections');
for (const title of ['Evidence', 'Analysis Notes', 'Confidence Reasoning']) {
const toggle = page.locator(`.section-toggle:has-text("${title}")`).first();
if (await toggle.count()) {
await toggle.scrollIntoViewIfNeeded();
await sleep(500);
await toggle.click();
await sleep(1100);
}
}
await sleep(800);
console.error('[gif] scene 8: mark Investigating');
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
await sleep(900);
const statusBtn = page.locator('.status-trigger').first();
if (await statusBtn.count()) {
await statusBtn.click().catch(() => {});
await sleep(1100);
const investigating = page.locator('text=Investigating').first();
if (await investigating.count()) {
await investigating.click().catch(() => {});
await sleep(1200);
}
}
console.error('[gif] scene 9: triage page (closing visual)');
await page.goto(URL_BASE + '/triage');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await sleep(1500);
}
async function convertWebmToGif(webm, gifOut) {
const palette = '/tmp/nyx-demo-palette.png';
console.error('[gif] generating palette');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm,
'-vf', 'fps=15,scale=1440:-1:flags=lanczos,palettegen',
palette,
], { stdio: 'inherit' });
console.error('[gif] palette → gif');
execFileSync('ffmpeg', [
'-y', '-ss', '1.0', '-i', webm, '-i', palette,
'-lavfi', 'fps=15,scale=1440:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
gifOut,
], { stdio: 'inherit' });
}
// CLI capture phase ----------------------------------------------------------
//
// `render-cli.py` orchestrates: force ANSI via `CLICOLOR_FORCE=1`,
// merge consecutive SGR escapes (freeze otherwise honors only the
// last one and drops fg/bg/dim), invoke freeze with the brand-
// consistent window chrome, then crop or pad to exactly 1600x992 so
// the framer never resamples the captured text.
const CLI_RENDERER = '/Users/elipeter/nyx/scripts/render-cli.py';
function renderCli(shellCommand, outFile) {
execFileSync(
'python3',
[CLI_RENDERER, outFile, shellCommand],
{ stdio: ['ignore', 'inherit', 'inherit'] },
);
}
// Stage a temporary HOME with a sample nyx.local so that
// `nyx config show` (which now defaults to a diff view) has
// something to display. Without this the capture would be a one-
// line "No overrides" notice — accurate but not a useful screenshot.
//
// The `directories` crate on macOS resolves the config path through
// `$HOME/Library/Application Support/nyx`, so swapping HOME is
// enough to redirect both reads and writes for the wrapped command.
const DEMO_CONFIG_HOME = '/tmp/nyx-demo-config-home';
const DEMO_NYX_LOCAL = `[scanner]
mode = "taint"
min_severity = "Medium"
[output]
default_format = "json"
max_low = 5
[analysis.engine]
backwards_analysis = true
`;
function stageDemoConfigHome() {
const cfgDir = join(DEMO_CONFIG_HOME, 'Library/Application Support/nyx');
rmSync(DEMO_CONFIG_HOME, { recursive: true, force: true });
mkdirSync(cfgDir, { recursive: true });
writeFileSync(join(cfgDir, 'nyx.local'), DEMO_NYX_LOCAL);
}
function captureCli() {
// Re-stage v1 so cli-scan output shows the richer set of findings
// (the previous --stills phase patched the demo to v2).
console.error('[cli/setup] writing v1 demo');
writeDemo('v1');
const out = (name) => join(OUT_DIR, 'docs', name);
// README and quickstart both link to the same `nyx scan` capture —
// emit it once at the top level, no docs/cli-scan-quickstart copy.
console.error('[cli] cli-scan');
renderCli(`${NYX_BIN} scan ${SCAN_ROOT}`, join(OUT_DIR, 'cli-scan.png'));
console.error('[cli] cli-failon');
// `; true` keeps the pipeline's exit code at 0 even when --fail-on
// trips. render-cli.py wraps the whole compound in `{ ...; }
// 2>/dev/null` so progress bars are suppressed for both halves.
renderCli(
`${NYX_BIN} scan ${SCAN_ROOT} --fail-on HIGH; true`,
out('cli-failon.png'),
);
console.error('[cli] cli-explain-engine');
renderCli(
`${NYX_BIN} scan ${SCAN_ROOT} --engine-profile deep --explain-engine`,
out('cli-explain-engine.png'),
);
console.error('[cli] cli-idxstatus');
renderCli(`${NYX_BIN} index status ${SCAN_ROOT}`, out('cli-idxstatus.png'));
console.error('[cli] cli-configshow (with staged nyx.local)');
stageDemoConfigHome();
renderCli(
`HOME=${DEMO_CONFIG_HOME} ${NYX_BIN} config show`,
out('cli-configshow.png'),
);
// cli-rollup-tail.png is intentionally not regenerated. Its alt text
// describes a 57-issue rollup that the synthetic demo cannot produce
// without a much larger fixture; the existing image is left alone.
}
// Frame phase ----------------------------------------------------------------
const STILLS_PNGS = [
'docs/serve-overview.png',
'docs/serve-findings-list.png',
'docs/serve-finding-detail.png',
'docs/serve-triage.png',
'docs/serve-explorer.png',
'docs/serve-scans.png',
'docs/serve-scan-detail.png',
'docs/serve-rules.png',
'docs/serve-config.png',
];
const CLI_PNGS = [
'cli-scan.png',
'docs/cli-failon.png',
'docs/cli-explain-engine.png',
'docs/cli-idxstatus.png',
'docs/cli-configshow.png',
];
function applyFrames(captured, { natural = false } = {}) {
// Frame only paths captured this run. Re-framing a previously-
// framed PNG would treat the framed result as the next inner
// content and produce a frame inside a frame.
const paths = captured.filter((p) => existsSync(p));
if (paths.length === 0) return;
const label = natural ? 'natural-size' : 'fixed';
console.error(`[frame] applying purple gradient frame (${label}) to ${paths.length} files`);
const args = natural ? ['--natural', ...paths] : paths;
execFileSync('python3', [FRAMER, ...args], { stdio: 'inherit' });
// Mirror the framed serve-overview.png to the top-level path the
// README links. Only do this when serve-overview was just
// captured this run; otherwise the existing top-level overview is
// already correct.
const ovSrc = join(OUT_DIR, 'docs/serve-overview.png');
const ovDst = join(OUT_DIR, 'overview.png');
if (paths.includes(ovSrc) && existsSync(ovSrc)) {
copyFileSync(ovSrc, ovDst);
console.error(`[frame] mirrored serve-overview.png → overview.png`);
}
}
// Main -----------------------------------------------------------------------
async function main() {
// Only the serve flows actually need nyx serve; --cli alone runs
// without it.
if (wantStills || wantGif) await waitForServer();
console.error('[setup] writing v1 demo to', SCAN_ROOT);
writeDemo('v1');
const needsBrowser = wantStills || wantGif;
let browser = null;
if (needsBrowser) {
const { chromium } = await import('playwright');
browser = await chromium.launch({ headless: true });
}
try {
if (wantGif) {
const videoDir = '/tmp/nyx-demo-video';
if (existsSync(videoDir)) rmSync(videoDir, { recursive: true });
mkdirSync(videoDir, { recursive: true });
const ctx = await browser.newContext({
viewport: VIEW,
colorScheme: COLOR_SCHEME,
recordVideo: { dir: videoDir, size: VIEW },
});
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
await captureGifFrames(page);
await page.close();
await ctx.close();
const fs = await import('node:fs');
const files = fs.readdirSync(videoDir).filter((f) => f.endsWith('.webm'));
if (files.length === 0) throw new Error('no webm captured');
await convertWebmToGif(join(videoDir, files[0]), join(OUT_DIR, 'demo.gif'));
} else if (wantStills) {
// --stills only: GIF didn't run, so we drive scan #1 ourselves.
console.error('[setup] running scan #1 (v1) via API');
const token = await csrfToken();
const before = await currentScanId();
await startScanViaApi(token);
await waitForScanComplete(before);
}
if (wantStills) {
const ctx = await browser.newContext({ viewport: VIEW, colorScheme: COLOR_SCHEME });
await ctx.addInitScript(() => {
try { localStorage.setItem('theme', 'light'); } catch {}
});
const page = await ctx.newPage();
// Phase 1: capture pages that benefit from many findings.
await captureStillsAfterScan1(page);
// Patch demo to v2 + run scan #2 silently to populate the
// trend chart with two data points (second one smaller).
console.error('[setup] patching demo to v2 + running scan #2 via API');
writeDemo('v2');
const token = await csrfToken();
const before = await currentScanId();
await startScanViaApi(token);
await waitForScanComplete(before);
// Phase 2: capture pages whose value depends on the trend or
// are independent of the scan history.
await captureStillsAfterScan2(page);
await ctx.close();
}
if (wantCli) {
captureCli();
}
if (wantStills || wantCli || wantGif) {
// Frame phase — only frame what was captured this run so that
// already-framed PNGs from prior runs aren't framed again.
// Stills and the GIF use the fixed 1600x992 inner; CLI captures
// use --natural so each command keeps its own height.
const fixed = [];
if (wantStills) fixed.push(...STILLS_PNGS.map((p) => join(OUT_DIR, p)));
if (wantGif) fixed.push(join(OUT_DIR, 'demo.gif'));
if (fixed.length) applyFrames(fixed, { natural: false });
if (wantCli) {
const cli = CLI_PNGS.map((p) => join(OUT_DIR, p));
applyFrames(cli, { natural: true });
}
}
} finally {
if (browser) await browser.close();
}
console.error('done');
}
main().catch((e) => {
console.error('FAIL:', e);
process.exit(1);
});

View file

@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Frame Nyx screenshots with the brand purple gradient.
Reads a list of PNG paths from argv (or all PNGs under
assets/screenshots/ if no args) and overwrites each with a framed
version: inner screenshot with rounded corners, centered on a
diagonal purple gradient (top-left #8a5bf5 → bottom-right #4d1d97).
Two framing modes:
- default inner is resampled to 1600x992, outer is 1800x1113.
Used for serve-* PNGs whose source is 1440x900.
- --natural inner is kept at its native size, outer grows to
match (inner + 100/60/100/61 padding). Used for
CLI captures whose height varies per command.
Usage:
python3 scripts/frame-screenshots.py path/to/foo.png ...
python3 scripts/frame-screenshots.py --natural path/to/cli.png ...
python3 scripts/frame-screenshots.py # frames the default set
Framing is not idempotent re-framing an already-framed image will
re-pad it, so callers are expected to keep raw captures separately or
re-capture before re-framing.
"""
from __future__ import annotations
import sys
from pathlib import Path
from PIL import Image, ImageDraw
# Frame geometry (matches existing docs/serve-*.png files).
OUTER_W, OUTER_H = 1800, 1113
PAD_L, PAD_T = 100, 60
INNER_W, INNER_H = 1600, 992
PAD_R = OUTER_W - INNER_W - PAD_L # 100
PAD_B = OUTER_H - INNER_H - PAD_T # 61
CORNER_RADIUS = 12
# Four-corner bilinear gradient. Sampled from the existing CLI
# screenshots so every framed asset matches: top-left is the lightest
# (Tailwind violet-500), the off-diagonal corners are violet-600, and
# bottom-right is violet-900.
GRAD_TL = (139, 92, 246) # #8b5cf6 violet-500
GRAD_TR = (124, 58, 237) # #7c3aed violet-600
GRAD_BL = (124, 58, 237) # #7c3aed violet-600
GRAD_BR = ( 76, 29, 149) # #4c1d95 violet-900
def make_gradient(w: int, h: int) -> Image.Image:
"""Bilinear gradient between the four GRAD_* corners.
Implemented row-by-row with PIL's linear-interpolation paste so a
1800x1113 canvas builds in a few hundred ms (vs ~10s for a pure-
Python pixel loop).
"""
# Top edge: TL → TR
top_row = Image.new("RGB", (w, 1))
top_pixels = top_row.load()
for x in range(w):
t = x / (w - 1) if w > 1 else 0.0
top_pixels[x, 0] = (
int(GRAD_TL[0] + (GRAD_TR[0] - GRAD_TL[0]) * t),
int(GRAD_TL[1] + (GRAD_TR[1] - GRAD_TL[1]) * t),
int(GRAD_TL[2] + (GRAD_TR[2] - GRAD_TL[2]) * t),
)
# Bottom edge: BL → BR
bot_row = Image.new("RGB", (w, 1))
bot_pixels = bot_row.load()
for x in range(w):
t = x / (w - 1) if w > 1 else 0.0
bot_pixels[x, 0] = (
int(GRAD_BL[0] + (GRAD_BR[0] - GRAD_BL[0]) * t),
int(GRAD_BL[1] + (GRAD_BR[1] - GRAD_BL[1]) * t),
int(GRAD_BL[2] + (GRAD_BR[2] - GRAD_BL[2]) * t),
)
# Vertically blend top row → bottom row across each column.
out = Image.new("RGB", (w, h))
for y in range(h):
t = y / (h - 1) if h > 1 else 0.0
# Per-row blend of the two edge images.
row = Image.blend(top_row, bot_row, t)
out.paste(row, (0, y))
return out
def round_corners(img: Image.Image, radius: int) -> Image.Image:
"""Apply rounded corners to img by masking alpha."""
mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).rounded_rectangle(
(0, 0, img.size[0], img.size[1]), radius=radius, fill=255
)
out = img.convert("RGBA")
out.putalpha(mask)
return out
def compose_frame(inner_rgb: Image.Image, gradient_bg: Image.Image) -> Image.Image:
"""Resize an RGB frame to the inner target and paste it onto the
pre-rendered gradient with rounded corners. Returns an RGB image
of OUTER_W x OUTER_H."""
inner = inner_rgb
if inner.size != (INNER_W, INNER_H):
inner = inner.resize((INNER_W, INNER_H), Image.LANCZOS)
inner_rounded = round_corners(inner, CORNER_RADIUS)
canvas = gradient_bg.copy()
canvas.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
return canvas.convert("RGB")
def compose_frame_natural(inner_rgb: Image.Image) -> Image.Image:
"""Frame an inner image at its native size with the same per-edge
padding as the fixed-size frame (100/60/100/61). Used for CLI
captures whose height varies per command short ones stay short,
long ones stay long, and nothing gets resampled."""
inner_w, inner_h = inner_rgb.size
outer_w = inner_w + PAD_L + PAD_R
outer_h = inner_h + PAD_T + PAD_B
bg = make_gradient(outer_w, outer_h).convert("RGBA")
inner_rounded = round_corners(inner_rgb, CORNER_RADIUS)
bg.paste(inner_rounded, (PAD_L, PAD_T), inner_rounded)
return bg.convert("RGB")
def frame_one(src: Path, natural: bool = False) -> None:
inner = Image.open(src).convert("RGB")
if natural:
out = compose_frame_natural(inner)
else:
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
out = compose_frame(inner, bg)
out.save(src, "PNG", optimize=True)
print(f"framed: {src}", file=sys.stderr)
def frame_gif(src: Path) -> None:
"""Frame an animated GIF in place: every frame gets the same
purple gradient frame, then the result is re-encoded as a single-
palette GIF. Calls ffmpeg for the final encode (Pillow's GIF
output is noticeably worse for large animations).
"""
import subprocess
import tempfile
from PIL import ImageSequence
src_img = Image.open(src)
bg = make_gradient(OUTER_W, OUTER_H).convert("RGBA")
durations: list[int] = []
with tempfile.TemporaryDirectory(prefix="nyx-gif-frames-") as tmp:
tmp_path = Path(tmp)
for i, frame in enumerate(ImageSequence.Iterator(src_img)):
rgb = frame.convert("RGB")
composed = compose_frame(rgb, bg)
composed.save(tmp_path / f"{i:05d}.png", "PNG")
durations.append(int(frame.info.get("duration", 67)))
if not durations:
print(f"no frames in {src}", file=sys.stderr)
return
avg_ms = sum(durations) / len(durations)
fps = max(1, round(1000.0 / avg_ms))
palette = tmp_path / "palette.png"
# palette pass
subprocess.run(
[
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", str(tmp_path / "%05d.png"),
"-vf", "palettegen=stats_mode=diff",
str(palette),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# encode
subprocess.run(
[
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", str(tmp_path / "%05d.png"),
"-i", str(palette),
"-lavfi", "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
"-loop", "0",
str(src),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print(f"framed gif: {src}", file=sys.stderr)
def main(argv: list[str]) -> int:
natural = False
if argv and argv[0] == "--natural":
natural = True
argv = argv[1:]
if not argv:
# No args: walk the default location.
root = Path(__file__).resolve().parent.parent / "assets" / "screenshots"
paths = sorted(p for p in root.rglob("*.png"))
else:
paths = [Path(p) for p in argv]
if not paths:
print("no PNGs to frame", file=sys.stderr)
return 1
for p in paths:
if not p.is_file():
print(f"skip (not a file): {p}", file=sys.stderr)
continue
if p.suffix.lower() == ".gif":
frame_gif(p)
else:
frame_one(p, natural=natural)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

70
scripts/render-cli.py Normal file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Render a shell command's output as a PNG sized to its content.
Pipeline:
1. Run the command, captured through `bash -c` so redirections and
env vars work, and force colors via `CLICOLOR_FORCE=1`. The whole
compound is wrapped in a brace group so `2>/dev/null` swallows
stderr even when the caller chains commands with `;`.
2. Pipe through `sgr-merge.py` to consolidate consecutive SGR
sequences (freeze otherwise honors only the last one).
3. Hand the colored stream to `freeze --execute` at width 1600 with
window chrome and let height auto-fit the content.
Output is freeze's natural-height capture — short commands stay short,
long commands stay long. The framer (`frame-screenshots.py --natural`)
wraps the result in the brand gradient at the matching outer size, so
no resampling or padding ever happens.
Usage:
python3 render-cli.py <out.png> <shell command...>
"""
from __future__ import annotations
import shlex
import subprocess
import sys
from pathlib import Path
OUT_W = 1600
SCRIPT_DIR = Path(__file__).resolve().parent
SGR_MERGE = SCRIPT_DIR / "sgr-merge.py"
def run_freeze(shell_cmd: str, out_png: Path) -> None:
"""Render the command at width 1600 and font size 22 with window
chrome. The brace group around the user command makes
`2>/dev/null` apply to the whole compound without it, a
`cmd; true` chain would only redirect `true`'s stderr and leak
progress bars from `cmd` into the capture."""
inner = (
f"{{ CLICOLOR_FORCE=1 {shell_cmd}; }} 2>/dev/null"
f" | python3 {shlex.quote(str(SGR_MERGE))}"
)
subprocess.run(
[
"freeze",
"--execute", f"bash -c {shlex.quote(inner)}",
"--output", str(out_png),
"--window",
"--width", str(OUT_W),
"--font.size", "22",
],
check=True,
stdout=subprocess.DEVNULL,
)
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: render-cli.py <out.png> <shell command...>", file=sys.stderr)
return 2
out = Path(argv[0])
shell_cmd = " ".join(argv[1:])
run_freeze(shell_cmd, out)
print(f"rendered: {out}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

36
scripts/sgr-merge.py Normal file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Merge consecutive ANSI SGR escape sequences into a single compound form.
freeze (charm.sh) renders only the most recently seen SGR escape, so
the `console` crate's habit of emitting separate `\x1b[34m\x1b[2m\x1b[4m`
sequences erases all but the last attribute. Pipe nyx output through
this filter to consolidate runs into `\x1b[34;2;4m` so freeze keeps
foreground, dim, and underline.
Usage: stream stdin -> stdout, e.g. `nyx scan | python3 sgr-merge.py`.
"""
from __future__ import annotations
import re
import sys
SGR_RUN = re.compile(r"(?:\x1b\[(\d+(?:;\d+)*)m){2,}")
def _merge(match: re.Match) -> str:
runs = re.findall(r"\x1b\[(\d+(?:;\d+)*)m", match.group(0))
return "\x1b[" + ";".join(runs) + "m"
def merge_sgr(s: str) -> str:
return SGR_RUN.sub(_merge, s)
def main() -> int:
data = sys.stdin.buffer.read().decode("utf-8", errors="replace")
sys.stdout.buffer.write(merge_sgr(data).encode("utf-8"))
return 0
if __name__ == "__main__":
sys.exit(main())