@symbo.ls/frank-audit
v3.14.772
Published
Source-level audit + verify-or-rollback fixer for Symbols projects. Detects sibling-imports, module-scope state, factory closures, legacy nested-syntax, and more — applies safe fixes that are verified against frank.toJSON and rolled back on regression.
Readme
@symbo.ls/frank-audit
Source-level audit and verify-or-rollback fixer for Symbols projects. Detects 59 classes of patterns across 10 rule families that either break frank's serialization, force frank's bundle-time fixer to guess, or violate the modern smbls runtime contract (design tokens, DOM bans, polyglot, helmet, router). Reports findings by default; --fix applies safe rewrites and verifies every change against frank.toJSON({scanAndFix:true}) — regressions roll back automatically. Findings the mechanical fixer refuses become structured prescriptions an LLM can answer with strict edit ops.
Schema: frank-audit/1.0. See RULES.md for the full per-rule reference.
Quick start
frank-audit # report findings (default — no writes)
frank-audit fix # apply safe fixes with verify-or-rollback
frank-audit verify # round-trip frank.toJSON; report bundleabilityfrank-audit looks for a symbols/ subdir of cwd; pass an explicit path as the first positional arg if not running from a project root.
Safety model
frank.toJSON already ships a runtime fixer (scanForSerializationIssues → classifyFreeVars → rewriteProject) that resolves many free-variable patterns at bundle time. frank-audit's job is to clean up those patterns at the source so what you commit matches what frank ships — never to make a project less publishable.
To do that, every fix run:
- Measures a baseline with
frank.toJSON({scanAndFix:true})— records bundle success/failure and remaining scan-issue count. - Snapshots every file before touching it.
- Applies fixes with risk-aware verify cadence:
saferules (FA101, FA102, FA007, …) — pure mechanical rewrites, no per-fix verify.mediumrules (FA103, FA104, FA105, FA106, FA205, …) — verify after the rule completes; rollback the rule's edits if the bundle regressed.riskyrules (FA001, FA006, FA008, FA201–204, FA206, …) — verify after EACH fix; rollback that single fix on regression.
- Final verify. If the net result regresses against baseline, rollback ALL fixes.
- Refuses to fix on a broken baseline. If
frank.toJSONalready fails before we touch anything, the audit reports the findings and refuses to make it worse. Pass--forceto override.
Every applied edit is also written to <symbolsDir>/.symbols_local/frank-audit/log as NDJSON keyed by opId for post-mortem inspection.
CLI reference
frank-audit <subcommand> [dir] [flags]Subcommands
| Subcommand | What it does |
| -- | -- |
| audit | List findings. Read-only. |
| fix | Apply auto-fixes (default: high-confidence only) with verify-or-rollback. |
| verify | Run frank.toJSON; report isFrankable + residualIssues. |
| prescriptions | Emit LLM-ready prescriptions for refused fixes (JSON). |
| apply-edits | Apply a JSON file of edit ops produced by an LLM. |
| explain <id> | Print docs for a rule (frank-audit explain FA205). |
| serve | Start the HTTP server (default 127.0.0.1:7117). |
| help | Print usage. |
Flags
| Flag | Effect |
| -- | -- |
| --json | Machine-readable JSON output. Implied when FRANK_AUDIT_JSON=1. |
| --strict | Exit non-zero on critical+high findings (CI mode). |
| --rule FA101,FA205 | Whitelist a subset of rules. |
| --aggressive | Apply medium-confidence fixes too (with fix). |
| --dry-run | Compute mutations, don't write. |
| --no-verify | Skip the frank.toJSON round-trip — faster, less safe. |
| --force | Apply fixes even on a broken baseline. DANGEROUS. |
| --ops <path> | JSON edit-ops file (with apply-edits). |
| --port <n>, --host <ip> | Bind address for serve. |
| --no-color, -v / --verbose, -h / --help | Standard. |
Exit codes: 0 clean / no findings, 1 findings detected (under --strict) or fix rolled back or op error, 2 CLI usage error.
Programmatic API
Two import surfaces — pick the one that matches your runtime:
// Local Node — fs + verify-or-rollback fixer + HTTP server
import { audit, fix, verify } from '@symbo.ls/frank-audit' // = './fs'
import { applyEditOps } from '@symbo.ls/frank-audit/fs'
const a = await audit('/path/to/symbols')
const fr = await fix('/path/to/symbols', a.findings, { aggressive: false })
const v = await verify('/path/to/symbols')// Worker / browser / pure-content — no fs, no subprocess
import {
auditContent, auditFiles,
validateEditOps, applyContentEditOps,
ALL_RULES, SCHEMA_VERSION
} from '@symbo.ls/frank-audit/core'
const r = auditContent(jsSourceString, { file: 'Card.js', slot: 'components' })The core tier is what the HTTPS MCP bundles for Cloudflare Worker mode. Anything that needs to touch disk, spawn frank, or run verify-or-rollback lives in fs. The default entry re-exports both.
Result envelopes
Every JSON-emitting surface returns the same envelope shape:
{
schema: 'frank-audit/1.0',
opId: 'audit-…', // per-run UUID, sortable
startedAt, completedAt, // ISO timestamps
…payload
}fix results add applied[], skipped[], edits[], errors[], baseline, finalState, rolledBack, passes. apply-edits results add mutatedFiles[], createdFiles[], deletedFiles[]. Errors come back as { ok:false, error: { code, message, category, retryable, details } } — see Error categories.
HTTP server mode
frank-audit serve --port 7117Wraps the fs-tier API in REST endpoints so other tools (MCP servers, CI runners, in-app debug panels) can call frank-audit without a subprocess hop.
| Endpoint | Body |
| -- | -- |
| GET /health | — |
| POST /audit | { dir, ruleIds? } |
| POST /fix | { dir, ruleIds?, aggressive?, dryRun?, verify?, force? } |
| POST /verify | { dir } |
| POST /prescriptions | { dir } |
| POST /apply-edits | { dir, ops, verify?, dryRun? } |
| POST /audit-content | { code, file?, slot?, ruleIds? } (no fs needed — pure) |
Every response carries the schema field and the same envelope shape as the CLI's --json output.
MCP integration
Both Symbols MCP servers delegate to frank-audit. They expose the same three tools to the LLM:
| MCP Tool | What it does |
| -- | -- |
| audit_and_fix_frankability(dir, mode) | mode='report' runs audit; mode='safe-fix' applies mechanical fixes via verify-or-rollback. |
| prescribe_frankability_fixes(dir) | Returns LLM-ready prescriptions for findings the mechanical fixer refused. |
| apply_frankability_edit_ops(dir, ops) | Applies LLM-emitted edit ops (validated, verified, rolled back on regression). |
Python stdio (symbols-mcp): _run_frank_audit shells out to the frank-audit CLI. Override the binary via FRANK_AUDIT_BIN=/path/to/frank-audit, or point at the HTTP server with FRANK_AUDIT_URL=http://127.0.0.1:7117. Single source of truth — no parallel rule implementation.
Node HTTPS (server/packages/symbols-mcp): same delegation via frank-audit-bridge.js, packaged as both Express server and Cloudflare Worker. The Worker target imports the core tier directly so the bundle stays Worker-safe; the Express server proxies to a local frank-audit HTTP server when one is reachable, otherwise spawns the CLI.
The LLM workflow is documented in symbols://skills/frank-fix-workflow (bundled into get_project_rules()).
Edit-op contract
Every fix the audit produces — and every fix an LLM can propose — is one of these 10 strict op kinds. Validated by validateEditOp before any IO, so malformed ops fail fast.
{ "kind": "removeImport", "file": "...", "specifier": "...", "source": "..." }
{ "kind": "moveFile", "from": "...", "to": "..." }
{ "kind": "addToIndexFile", "dir": "...", "filename": "..." }
{ "kind": "addToGlobalScope", "name": "...", "value": <json>, "valueIsCode": false }
{ "kind": "removeTopLevelDecl", "file": "...", "name": "..." }
{ "kind": "addElementScope", "file": "...", "componentName": "...", "key": "...", "value": <json>, "valueIsCode": false }
{ "kind": "replaceTokenValue", "file": "...", "line": <n>, "oldValue": "...", "newValue": "..." }
{ "kind": "skip", "reason": "..." }One-line examples:
{ "kind": "removeImport", "file": "components/Card.js", "specifier": "fmtMoney", "source": "../functions/utils.js" }
{ "kind": "moveFile", "from": "utils/helpers.js", "to": "functions/helpers.js" }
{ "kind": "addToIndexFile", "dir": "components", "filename": "Card.js" }
{ "kind": "addToGlobalScope", "name": "TAX_RATE", "value": 0.07 }
{ "kind": "removeTopLevelDecl", "file": "components/Counter.js", "name": "count" }
{ "kind": "addElementScope", "file": "components/Counter.js", "componentName": "Counter", "key": "count", "value": 0 }
{ "kind": "replaceTokenValue", "file": "components/Card.js", "line": 12, "oldValue": "16px", "newValue": "A" }
{ "kind": "skip", "reason": "intent unclear — needs human review" }value is a JSON literal by default. Set valueIsCode: true to opt into a JS expression string (the validator parses it to verify syntax). The core exports validateEditOps and applyContentEditOps for callers that want to validate or simulate without touching disk.
Architecture
plugins/frank-audit/
├── bin/frank-audit.js single CLI binary, all subcommands
├── index.js re-exports core + fs
├── src/
│ ├── core/ Worker-safe — pure logic, no fs
│ │ ├── auditContent.js auditContent / auditFiles entry points
│ │ ├── classifier.js per-finding decision: scope vs globalScope vs leave vs ask
│ │ ├── constants.js FRANK_DIRS, FLAT_HTML_ATTRS, KNOWN_GLOBALS, …
│ │ ├── contentIndex.js in-memory index for pure auditing
│ │ ├── editOps.js 10 op kinds + validator + pure applier
│ │ ├── parser.js babel + recast wrapper, source-preserving
│ │ ├── rules/ one file per rule (59 total), each exports
│ │ │ { id, severity, confidence, risk, category,
│ │ │ detect, fix?, explain }
│ │ ├── report.js text + JSON output, --explain rendering
│ │ ├── scopeMoverHelpers shared FA20x AST helpers
│ │ └── hardening/ schema versioning, opId, error categories,
│ │ NDJSON audit-log builders
│ └── fs/ Local Node — adds project ops + fixer
│ ├── audit.js public API: audit, fix, verify
│ ├── indexer.js walks symbols/, builds the project index
│ ├── fixer.js atomic write + verify-or-rollback engine
│ ├── verifier.js round-trip frank.toJSON
│ ├── parser.js parseFile / writeBack
│ ├── prescriptions.js builds LLM prescriptions from refused fixes
│ ├── applyEditOps.js fs-tier op applier (snapshot + verify + rollback)
│ └── serve.js HTTP server (frank-audit serve)
└── RULES.md full per-rule reference (generated)Rule families
| Family | Count | What |
| -- | -- | -- |
| FA0xx | 5 | Discovery + structure (sibling-imports, orphan files, index completeness, name mismatches) |
| FA1xx | 7 | Flat-syntax cleanup (el.props.X → el.X, on:{} → onX, attr:{} flattening, handler signature) |
| FA2xx | 6 | Scope movers — module-state, multi-file helpers/constants, single-component constants, factory closures |
| FA3xx | 4 | Design-token enforcement (hex/rgb/hsl/raw px) |
| FA4xx | 8 | Modern-stack bypass (window.location, fetch, axios, XHR, document.title, theme writes, polyglot) |
| FA5xx | 12 | DOM bans (querySelector, classList, innerHTML, addEventListener, append/remove/insert, traversal) |
| FA6xx | 4 | Icon bans (raw <svg>, inline SVG html) |
| FA7xx | 1 | Polyglot hints (hardcoded English text — heuristic) |
| FA8xx | 8 | Structural details (page-extends-page, lowercase keys, redundant Flex/Box/Text wrappers, extends variables) |
| FA9xx | 4 | Advisory (unresolvable free vars, side-effect imports, component-as-function, circular globalScope) |
16 rules ship with auto-fix; 43 are detect-only and become prescriptions for the LLM.
Error categories
Every failure carries a structured category so consumers can decide what to do:
| Category | Meaning | Retryable |
| -- | -- | -- |
| user-error | Caller passed bad input (invalid path, malformed op, bad rule id) | no |
| transient | Temporary condition (file lock, ENOMEM, network) | yes |
| permanent | Structural problem (broken baseline, parse error in source, missing dep) | only after the user fixes the underlying issue |
| internal | Bug in frank-audit itself | no — file an issue |
What --fix does NOT promise
- It does not guarantee zero remaining findings. Many rules are detect-only by design (advisory, design-token catches, DOM bans), and even fixable rules are skipped when applying them would regress the bundle. Skipped findings appear under
--verboseand become prescriptions viaprescriptions. - It does not make every project frankable. Projects with deeper architectural issues (broken handler bodies, circular globalScope dependencies, side-effecting module-init) need either an LLM via the prescription path or human judgment.
- It does not change
frank's runtime behaviour. Frank's bundle-time fixer continues to do its work; frank-audit just reduces how much frank has to guess at. - It does not move files across packages or rewrite shared-library code. Anything resolved through
sharedLibraries.jsis read-only — if a fix would require editing a shared-package file, it is skipped with a refusal reason.
Decisions
- Recast, not raw
@babel/generator— preserves untouched formatting on--fix. - Source stays clean (bare references), frank does the
__scope.Xinjection at bundle time.--aggressiveopts into rewriting source toel.scope.Xdirectly so devs see the mechanics. - Idempotent. Running twice with no source changes produces no edits.
- Atomic with verify-or-rollback. Risky fixes verify against
frank.toJSONbefore being kept; the whole batch rolls back if the net result regresses against baseline.
Further reading
RULES.md— full per-rule reference (every rule with severity, confidence, risk, auto-fix, examples).symbols://skills/frankability— narrative companion to RULES.md (wrong-vs-canonical patterns).symbols://skills/frank-fix-workflow— the LLM's reference card for the prescription → edit-op flow.@symbo.ls/frank— the serializer this audit aligns the source against.
