Welcome#
VibeDrift is a drift scanner for AI-assisted codebases. It measures one thing: how far a repository has drifted from its own dominant conventions. It learns the patterns a codebase already agrees on (by majority vote, with evidence), then flags the files that deviate and reports the result as a Vibe Drift Score.
That identity is the most important sentence in this handbook, because it rules out two things people expect from a code scanner:
- VibeDrift is not a generic code-quality tool. SonarQube, Semgrep, and CodeQL already exist. A finding here needs a baseline it deviates from; a peer group is a precondition, not a nice-to-have.
CONTRIBUTING.mdstates the test every change must pass: does this make us measure drift more accurately or more confidently? - VibeDrift is not a vulnerability scanner. The Security Consistency category measures how uniformly a repo applies its own auth and validation patterns, not the absence of vulnerabilities. The terminal report renders that disclaimer permanently under the security bar (
src/output/terminal.ts): consistent does not mean safe.
The distinction is enforced in code, not just in copy. Every signal is classified as either drift-kind (grounded in a dominance vote, similarity signal, or taint flow) or hygiene-kind (classic linter territory: empty catches, dead code, generic security rules). Only drift-kind findings feed the headline Vibe Drift Score; hygiene findings render in their own pane with their own parallel score (src/scoring/categories.ts). Chapter 08 covers the mechanics.
Concretely, the product in this repo is a TypeScript CLI (npx @vibedrift/cli) and an MCP server, published as @vibedrift/cli. It analyzes JavaScript, TypeScript, Python, Go, and Rust and runs its analysis locally; the hosted service is used only for the optional metered deep scan, the dashboard sync for signed-in users, a disclosed opt-out anonymous beacon, and a daily update check (chapter 11 walks the exact boundary).
Who this handbook is for#
A developer who is new to this codebase and wants to change it: add an analyzer or a drift detector, extend a language, fix a scoring behavior, or embed the in-loop tools somewhere new. It assumes you are a competent engineer and assumes nothing about this repo. Chapter 2 is the map; the rest of the chapters walk the actual code paths, constants, and invariants, and say why they are the way they are.
Reading map#
Each chapter answers one question. Read 01 through 03 in order; after that, chapters stand alone.
| Chapter | The question it answers |
|---|---|
| 02 System Architecture | What are the layers, what runs locally versus in the cloud, and what lives in each src/ directory? |
| 03 The Scan Pipeline | What happens, step by step, when vibedrift scan . runs? |
| 04 Layer 1: Static Analyzers | What does each of the 13 per-file analyzers detect, and how? |
| 05 Cross-File Drift Detection | How does the dominance vote work, and what do the 14 cross-file detectors flag? |
| 06 Security Consistency: Auth Drift Across Languages | How is auth drift detected per language, and what does never-false-bless mean? |
| 07 Layer 1.7: Code DNA | How are functions fingerprinted and near-duplicates found, all locally? |
| 08 Scoring: From Findings to the Vibe Drift Score | How do findings become the composite score and the Hygiene Score? |
| 09 The MCP Server: Drift Checks in the Agent Loop | How does an AI agent query the repo's conventions while writing code? |
| 10 Output Surfaces | How do results render: terminal, HTML, JSON, CSV, DOCX, and the committable context files? |
| 11 The Local/Cloud Boundary | Exactly what does the optional deep scan send, receive, and refuse to send? |
| 12 Testing, Calibration, and CI | How is correctness enforced: unit suites, fixtures, and the calibration gates? |
| 13 Extending VibeDrift | Step-by-step recipes: new analyzer, new detector, new language, new output format. |
| 14 Glossary | Every term of art in the handbook, one definition each. |
How to update this handbook#
The handbook is built from the markdown in this directory. The HTML at the repo root is a build artifact; never edit it by hand.
- Edit the relevant chapter in
docs/handbook/NN-*.md(or add a newNN-*.mdfile; chapters compile in filename order). - Rebuild with
npm run handbook(which runsnode scripts/build-handbook.mjs; the build has zero dependencies and runs on Node 20+, the repo's engines floor). - Commit the changed markdown together with the regenerated HTML.
The full conventions (supported markdown subset, SVG diagram palette, writing rules) live in docs/handbook/README.md. Two rules from there worth repeating: every claim about the code must be verified against the actual source before it lands, and when a code change makes a chapter stale, fix the chapter in the same PR.
docs/handbook/01-welcome.md, then rebuild with node scripts/build-handbook.mjsSystem Architecture#
VibeDrift is organized as a layered analysis pipeline. Each layer adds a more expensive kind of evidence, and the layers are strictly ordered by where they run: everything up to and including Layer 1.7 runs on the user's machine, and only the optional Layer 2 crosses the network. This chapter gives you the map: the layers, the repository layout, the high-level dataflow, and the design principles you will see enforced throughout the code.
The layers#
| Layer | Where | What it adds | Runs |
|---|---|---|---|
| 1 (static) | src/analyzers/ | 13 per-file static analyzers: naming, imports, error handling, complexity, security rules, dead code, dependencies, and more | local, free |
| 1 (drift) | src/drift/ | 14 cross-file drift detectors built on dominance voting: "8 of 10 files do X, these 2 deviate" | local, free |
| 1.7 (Code DNA) | src/codedna/ | Semantic analysis over extracted function bodies: exact-duplicate fingerprints, MinHash/LSH near-duplicates, operation sequences, taint flows, deviation heuristics | local, free |
| 2 (deep scan) | src/ml-client/, src/mcp/deep-client.ts | A thin client for the hosted service: embedding-based duplicate detection, name-versus-behavior intent checks, anomaly detection, and LLM validation of borderline cases, all computed server-side | cloud, optional, metered |
Layer 1 is two registries. createAnalyzerRegistry() in src/analyzers/index.ts returns the 13 static analyzers; createDriftDetectors() in src/drift/index.ts returns the 14 drift detectors, which map onto 13 drift categories (commit-archaeology folds its findings into architectural_consistency). Chapters 04 and 05 cover them individually.
The registries are the source of truth for these counts. Older docs in and around this repo cite "12 analyzers" or "8 detectors"; when a doc and a registry disagree, trust src/analyzers/index.ts and src/drift/index.ts.
Layer 1.7 (src/codedna/) extracts functions once, then runs five analyses over them: semantic fingerprinting for exact duplicates, operation-sequence similarity, pattern classification, taint analysis, and deviation heuristics. Its MinHash/LSH machinery (src/codedna/minhash.ts) doubles as a shared primitive used by the duplicates analyzer, the semantic-duplication drift detector, the deep-scan sampler, the MCP baseline, and the one-vs-many body search. Chapter 07 covers it.
Layer 2 is deliberately thin in this repo: request shaping, response filtering, and nothing else. When a scan runs with --deep and a signed-in token, runMlAnalysis (src/ml-client/index.ts) samples at most 30 function snippets of at most 60 lines each and posts them to the hosted /v1/analyze endpoint; embeddings and Claude validation run server-side, never in this repo. Duplicate, intent, and anomaly findings the server returns merge into the local result only at confidence 0.85 or higher (src/ml-client/confidence.ts), as ml-duplicate, ml-intent, and ml-anomaly; panel-confirmed reimplementation findings arrive already validated server-side and bypass that gate. The whole path is opt-in, metered, and fail-soft: if the call fails for any reason, the local scan completes and renders without it. Chapter 11 walks the boundary in detail, including exactly what is and is not transmitted.
The scan dataflow, briefly#
A scan (runScan in src/cli/commands/scan.ts) follows a fixed order: discover files and manifests (src/core/discovery.ts), parse them with tree-sitter (src/utils/ast.ts), run the static analyzers concurrently and reassemble findings in registry order, run drift detection, run Code DNA, optionally run the deep scan and cross-layer dedup, then hand everything to computeScores (src/scoring/engine.ts), which produces two parallel tracks: the Vibe Drift Score from drift-kind findings and the Hygiene Score from hygiene-kind findings. After scoring come the side effects (history, the MCP baseline, the anonymous beacon) and rendering. Chapter 03 walks every step with the flags that alter it; chapter 08 covers the scoring math.
Repo map#
Every directory under src/, one line each. All verified against the tree.
| Path | Purpose |
|---|---|
src/analyzers/ | The 13 per-file static analyzers plus their registry (index.ts) and shared Analyzer interface (base.ts) |
src/auth/ | Device auth flow, token storage and resolution (~/.vibedrift/config.json, mode 0600), plan gates |
src/cli/ | The Commander.js program (index.ts) and one file per command under commands/ (the telemetry and mcp commands are wired inline in index.ts); scan.ts is the orchestrator |
src/codedna/ | Layer 1.7: function extraction, fingerprints, MinHash/LSH, op sequences, pattern classifier, taint, deviation heuristics, non-shippable path filter |
src/core/ | The spine: discovery, types.ts (SourceFile, AnalysisContext, Finding, ScanResult), import graph, baseline, scan history, findings cache, project config |
src/data/ | The bundled peer-percentile corpus (score_percentiles.json); currently a placeholder with an empty languages map, so percentile lookups return null |
src/drift/ | The 14 cross-file detectors, the dominance-vote utilities (utils.ts), the per-language security AST extractors, pivot detection, suppression audit |
src/intent/ | Parses team-declared conventions from CLAUDE.md, AGENTS.md, and .cursorrules into IntentHints that seed the dominance vote |
src/mcp/ | The stdio MCP adapter (server.ts, tools/), plus the baseline provider and the deep-check plumbing (deep-client.ts, deep-index.ts, candidate-feeder.ts) |
src/ml-client/ | The Layer 2 wire client: sampler, confidence filter, embedding client, scan logging, result sanitization, report upload |
src/output/ | Pure presentation over a ScanResult: terminal, HTML, CSV, DOCX, context files, fix plans, history diff, floor badge, tease |
src/scoring/ | The engine (noisy-OR damage, geometric-mean composite), the category and kind registry (categories.ts), cross-layer dedup |
src/telemetry/ | The anonymous scan beacon and the report-open beacon (beacon.ts) |
src/tools-core/ | Channel-neutral implementations of the six tools (the five in-loop query/validate tools plus init setup), plus the nudge and finalize helpers; imports no MCP SDK |
src/types/ | Ambient TypeScript declarations (tree-sitter-wasms.d.ts) |
src/utils/ | Tree-sitter grammar loading (ast.ts), .gitignore/.vibedriftignore handling, small text and math helpers |
src/render.ts | The public ./render re-export surface: renderHtmlReport, computeScores, estimateScoreAfterFixes |
One structural quirk worth knowing early: src/tools-core/ is the transport-free core of the in-loop tools, and src/mcp/ is its stdio adapter, but baseline-provider.ts and the deep-check clients live under src/mcp/ while being imported by tools-core. Neither directory is redundant. Chapter 09 covers the port-and-adapter split.
Design principles as practiced#
These are not aspirations; each one names the code that enforces it.
Local-first#
A scan works with zero network. --local-only gates every network call at one choke point in src/cli/commands/scan.ts: the auth banner, deep analysis, scan logging, fix-prompt synthesis, the beacon, and the update check. The MCP tools likewise run locally against a cached baseline and need no login. All state lives under ~/.vibedrift/ in directories keyed by a hash of the project path; nothing is written into the project tree except the opt-in .vibedrift/ context files, .vibedriftignore, the opt-in --inject-context block in CLAUDE.md, and report files (with --output, or the local fallback write when a dashboard upload fails).
The honest caveat, stated here because the docs must never overclaim: a default scan does send one anonymous beacon and a daily npm update check, both disclosed and opt-out (chapter 10 lists the exact beacon fields). "Local-first" means the analysis is local and offline operation is a supported first-class mode, not that the default configuration makes zero network calls.
Honesty over confidence#
When the engine is not sure, it hedges or stays silent instead of guessing. The pattern recurs at every layer:
- The security detectors resolve auth to a three-way outcome (
auth,not-auth,unsure), andunsurenever counts as authenticated; it only softens the finding copy to "auth not confirmed, double check hook '...'". The strongest form is the never-false-bless law: the detector may over-flag, but it must never mark an unauthenticated route as authenticated (chapter 06). - Surface-specific score categories with zero findings render as an explicit
N/Aline ("no findings in this repo") rather than earning a free 20/20, and the composite line carries an explicit scope note, "(over N of M categories)", so the headline never silently implies a full verdict (src/output/terminal.ts). - The peer-percentile line renders nothing at all while the bundled corpus in
src/data/is empty, for free and paid users alike: no capability is advertised that would currently return nothing. - The deep-scan teaser names candidate counts, never unconfirmed pairs.
- When the scoring methodology changes, score deltas across versions are refused rather than computed, because subtracting numbers produced by different formulas misleads (
src/core/history.ts,src/core/scoring-notice.ts).
Determinism and testability#
Same commit, same score. Discovery sorts directory entries by code-unit comparison and re-sorts the final file list (src/core/discovery.ts); analyzers run concurrently but their findings are reassembled in registry declaration order (src/core/run-analyzers.ts); finding digests bucket line numbers by floor(line/3) and normalize numbers out of messages so scan-over-scan diffs survive small edits (src/core/history.ts). Every cache carries a version knob that invalidates it wholesale when logic changes: Analyzer.version, BASELINE_VERSION, HISTORY_SCHEMA_VERSION, and SCORING_VERSION for the methodology itself.
Testability is enforced by calibration gates, not just unit tests: npm run calibrate measures per-detector precision and recall against synthetically injected drift and fails outright if the security-floor row drops below 0.95 precision; npm run calibrate:monotonic asserts the composite falls monotonically as injected drift rises; and the per-language security fixture suites (scenario families S0 through S11) run on every npm test. Chapter 12 covers all of them.
Zero-dependency bias, where practical#
When a small amount of hand-rolled code can replace a dependency, this codebase prefers the hand-rolled code: the DOCX renderer writes a minimal OOXML ZIP itself (deflateRawSync plus a CRC32 table in src/output/docx.ts) instead of pulling in a docx library; the --include/--exclude globs are matched by a dependency-free glob-to-regex translation (src/core/file-filter.ts); and this handbook's own build (scripts/build-handbook.mjs) is a zero-dependency Node script. The CLI is typically run via npx in other people's projects, so a small dependency tree keeps startup light and the supply-chain surface small. The bias is practical, not dogmatic: tree-sitter, Commander, and the MCP SDK earn their place.
docs/handbook/02-architecture.md, then rebuild with node scripts/build-handbook.mjsThe Scan Pipeline#
vibedrift scan is the product. Everything else in the CLI (auth, watch mode, the MCP server, the git hook) exists to feed it or to consume its output. This chapter walks the pipeline end to end, from the moment the command parses to the moment a report renders, and closes with the full command surface and the configuration model.
The design constraint that shapes the whole pipeline: a scan must be complete, useful, and deterministic without any network access. Network stages (deep scan, dashboard upload, the anonymous beacon) are strictly additive and individually skippable, and --local-only turns all of them off at once.
Orchestration overview#
The orchestrator is runScan in src/cli/commands/scan.ts. In order, it:
- Validates and resolves the target path. A bare argument that happens to match a known subcommand name (from a newer CLI than the one installed) triggers a "your CLI is out of date" hint instead of a confusing "path not found" error.
- Resolves auth.
--deepwithout a token exits with code 1 and a login hint; free scans do best-effort token resolution so results can sync to the dashboard. Under--local-onlythis step is skipped entirely. - Kicks off a passive update check in parallel with the scan (it respects the telemetry opt-out).
- Runs the local analysis pipeline (
runAnalysisPipeline): discovery, parsing, static analyzers, drift detectors, Code DNA. - Optionally runs the deep scan (
runDeepAnalysis) and cross-layer dedup. - Builds the
ScanResult(buildScanResult): security floor re-tag, scoring, scan-over-scan diff. - Writes the MCP baseline as a best-effort side effect, so the MCP server cold-starts without a full re-scan.
- Fires the anonymous scan beacon (unless telemetry is disabled or
--local-only). - Shows the one-time scoring-refined notice if the scoring methodology changed since the user's last scan (see the scoring chapter).
- Persists the scan to history, writes context files if
--write-contextwas passed, then uploads (if authenticated) and renders.--fail-on-scoreexits 1 when the composite lands below the threshold.
The subsections below take these stages in pipeline order.
Discovery#
Discovery (buildAnalysisContext in src/core/discovery.ts) walks the project tree and assembles the AnalysisContext every later stage reads: the source files with content, the language breakdown, project manifests (package.json, go.mod, Cargo.toml, requirements.txt or pyproject.toml, .env.example), git metadata, and intent files. The independent loads run under Promise.all.
Not everything on disk should be analyzed. Discovery applies four filters, each targeting a different failure mode:
| Filter | Rule | Why |
|---|---|---|
| Skip directories | SKIP_DIRS (node_modules, .git, dist, build, .next, .nuxt, target, vendor, __pycache__, .venv, venv, coverage, .turbo, .cache, .idea, .vscode) plus any directory starting with . | Dependency and build output is not the user's code |
| Ignore files | .gitignore and .vibedriftignore, both parsed with the ignore package | User-declared exclusions, one syntax |
| Vendored/minified files | VENDORED_FILE_RE (*.min.js, *.bundle.js, and variants), plus any file containing a line longer than MAX_SOURCE_LINE_LENGTH (2000 chars) | A checked-in jquery bundle would drown the drift vote in functions nobody wrote; hand-written source effectively never has a 2000-character line, so line length catches bundles regardless of filename |
| Caps | MAX_FILE_SIZE 1 MiB per file, MAX_FILE_COUNT 5000 files (with a truncation warning) | Bound memory and scan time on pathological inputs |
Language detection is extension-based (src/core/language.ts): .js/.jsx/.mjs/.cjs, .ts/.tsx/.mts/.cts, .py, .go, .rs. Files in any other language are skipped at discovery time, so the rest of the pipeline never sees a file it cannot analyze.
Discovery is where scan determinism starts. Directory entries are sorted by plain code-unit comparison, deliberately not localeCompare (which is itself locale-dependent), because readdir order is filesystem-dependent: APFS returns sorted entries, ext4 returns hash order. The flattened file list is then re-sorted by relativePath. Every downstream map keyed by file inherits this order, so dominance-vote tie-breaks and MAX_FILE_COUNT truncation resolve identically on every machine and every clone.
After discovery, --include/--exclude globs filter the file set (followed by a stats recompute so line counts match the filtered set), and --diff [ref] scopes ctx.files to git-changed files. --diff falls back to a full scan with a warning when the directory is not a git repo, and exits 0 immediately when nothing changed. An empty file set prints "No source files found to analyze." and exits 0.
Parsing: tree-sitter with a regex safety net#
parseFiles (src/utils/ast.ts) attaches a tree-sitter AST to each discovered file. The whole parsing layer is 68 lines, but two decisions in it are load-bearing.
First, the dependency pinning. VibeDrift uses web-tree-sitter (the WASM build of tree-sitter, so there is no native compilation step at install time) with grammars from the tree-sitter-wasms package. Two workarounds live here:
- The grammar WASM files are loaded by direct file path, because
tree-sitter-wasms'mainfield points at a nonexistentbindings/nodeand importing the package throws. web-tree-sitteris pinned to^0.25.10inpackage.json, because 0.26.x cannot load these grammar files at all (tree-sitter issue #5171, a wasm dylink ABI mismatch with grammars built by older tree-sitter-cli versions).
// src/utils/ast.ts
const pkgJson = require.resolve("tree-sitter-wasms/package.json");
const wasmPath = `${pkgJson.slice(0, -"package.json".length)}out/tree-sitter-${grammarName}.wasm`;
const language = await Language.load(wasmPath);Five grammars map to the five supported languages, with one special case: .tsx files get the tsx grammar, because the plain typescript grammar does not understand JSX. Loaded grammars are cached in a module-level Map so each grammar's WASM is loaded once per process.
Second, the failure mode. parseFile wraps everything in try/catch and returns null on any error; parseFiles stores undefined for the tree. Every analyzer that consumes ASTs checks file.tree and falls back to regex heuristics when it is absent (for example src/analyzers/naming.ts, src/analyzers/complexity.ts, src/analyzers/dependencies.ts all carry a regex path). A parse failure therefore degrades one file's analysis quality; it never aborts the scan. This matters in practice because real repos contain syntactically broken files mid-refactor, and a scanner that dies on them is a scanner people stop running.
The concurrent analyzer pass (Layer 1, static)#
createAnalyzerRegistry() (src/analyzers/index.ts) returns the 13 static analyzers. runAnalyzers (src/core/run-analyzers.ts) runs all of them concurrently via Promise.all and then reassembles their findings in declaration order.
Concurrency is safe because analyzers are pure and read-only over the context, and Promise.all preserves array order, so the flattened result is byte-identical to a sequential loop. The win is overlapping each analyzer's cache I/O; CPU-bound AST work still serializes on Node's single thread (worker threads are a deliberate deferral).
Each analyzer's output is cached in ~/.vibedrift/findings-cache/<project-hash>/ under a Merkle-style key: sha256 over the analyzer id, the analyzer's version field, and the sorted relativePath:contentHash tuples of the files that analyzer applies to (src/core/findings-cache.ts). Change one applicable file and only that analyzer's key changes; bump an analyzer's version when its logic changes and its stale cache invalidates itself. The cache has a 30-day TTL and a 500 MB cap, is pruned in the background after each scan, and is disabled by --no-cache. Chapter 4 covers the analyzers themselves.
Cross-file drift detection (Layer 1, drift)#
runDriftDetection (src/drift/index.ts) runs the 14 cross-file drift detectors returned by createDriftDetectors: architectural contradiction, convention oscillation, security consistency, semantic duplication, phantom scaffolding, import consistency, export consistency, async consistency, return-shape consistency, logging consistency, comment-style consistency, state-management consistency, test-structure consistency, and commit archaeology.
Unlike static analyzers, drift detectors judge each file against the repo's own dominant pattern: most of them build directory-scoped dominance votes (minimum group size 3, dominance threshold 0.7, entropy-gated, recency-weighted; the mechanics live in src/drift/utils.ts and the scoring chapter explains how the vote's outputs are consumed). Two enrichment passes then run over the raw findings: a pivot pass (detectPivotsAcrossFindings) that reclassifies deviating files as legacy migration candidates when git history shows a temporal majority shift, and an intent-divergence pass that stamps a finding with provenance when a team-declared pattern (from CLAUDE.md and similar intent files) disagrees with the voted dominant, so the UI can say "you declared X, the code does Y."
Finally each DriftFinding converts to the standard Finding shape via driftFindingToFinding, taking analyzerId = "drift-<driftCategory>" (for example drift-import_style). From this point on, static and drift findings flow through one shared pipe.
Code DNA (Layer 1.7)#
Unless --no-codedna is passed, runCodeDnaAnalysis (src/codedna/index.ts) runs five modules over a shared function extraction: semantic fingerprinting with duplicate grouping, operation-sequence similarity, pattern classification, taint analysis, and deviation heuristics. Its findings carry codedna-* analyzer ids (codedna-fingerprint, codedna-pattern, codedna-taint, codedna-deviation) and merge into the same findings stream; operation-sequence similarities are deliberately never surfaced as findings (workflow-shape similarity is a consistency signal, not duplicate evidence), and the sequenceSimilarities data is instead retained on the result for the report surfaces and the deep-scan tease. Code DNA is local and free; it exists to catch semantic redundancy and pattern deviation that line-level static analysis cannot see. It has its own chapter.
Deep scan (Layer 2): the interface#
Deep scan runs only when all three of --deep, a resolved bearer token, and not --local-only hold. runDeepAnalysis (src/cli/commands/scan.ts) calls runMlAnalysis (src/ml-client/), which POSTs a sampled set of function snippets (at most 30, at most 60 lines each) to the VibeDrift API; only the highConfidence findings that come back merge into the local findings (validation of borderline findings happens server-side, and unresolved "maybe" findings are deliberately not shipped as findings). On any failure the error prints and the local scan continues unchanged. After the merge, deduplicateFindingsAcrossLayers (src/scoring/dedup.ts) collapses the same duplicate pair reported by multiple layers, keeping the highest-priority detection: ml-duplicate over codedna-fingerprint over codedna-opseq over duplicates. Note that this dedup pass only exists on the deep path; a free scan never calls it because static and Code DNA duplicate findings are distinct enough to keep.
Building the result: floor re-tag, scoring, diff#
buildScanResult (src/cli/commands/scan.ts) turns the raw findings into the ScanResult that everything downstream consumes:
- It loads the previous scan's scores, hygiene scores, and
scoringVersionfrom history, so deltas and the scan-over-scan diff have a baseline. - Security min-peer floor, applied to the render set.
applySecurityMinPeerFloor(src/scoring/engine.ts) re-tags route-consistency security findings backed by fewer than 4 peer routes to an advisory hygiene id, in place, before the findings becomeresult.findings. The scoring engine applies the same floor internally on its own copy; applying it here too means every renderer sees the demoted id, so no export can contradict the category's N/A.scoredDriftView(src/drift/index.ts) performs the matching filter on the raw drift-finding view at one source point for the same reason. computeScoresproduces the drift and hygiene score tracks, per-file scores, the peer percentile, and thepreviousScoresMismatchflag (the scoring chapter covers all of it).- Scan-over-scan diff. On by default when history exists (
--no-compareopts out,--since <scanId>picks a specific baseline),diffScanscompares finding digests: sha256-16 keys overanalyzerId, file, the line bucketed byfloor(line/3)(so a finding survives small edits above it), and the message with numbers normalized toN. Digests, not full findings, because history stores at most 200 finding digests and 100 drift digests per scan.
Persistence#
All persistent state lives under ~/.vibedrift/, keyed by a hash of the project path, never inside the project tree. The only project-tree writes are the opt-in .vibedrift/ context files, .vibedriftignore, and reports you explicitly ask for.
| Store | Path | Contents |
|---|---|---|
| Scan history | ~/.vibedrift/scans/<project-hash>/scan-<ts>.json | Timestamp, both score tracks, finding digests, scoringVersion; schema v3, retention 10 scans (src/core/history.ts) |
| Findings cache | ~/.vibedrift/findings-cache/<project-hash>/ | Per-analyzer findings keyed by content hashes; 30-day TTL, 500 MB cap |
| MCP baseline | written by assembleBaseline + writeBaseline as a scan side effect | The repo drift baseline the MCP server reads to answer in-loop queries without a cold scan |
| User config | ~/.vibedrift/config.json | Auth token, telemetry preference, cached plan, lastSeenScoringVersion |
One subtlety: history saves the raw drift-finding digests, not the floor-filtered render view, so scan-over-scan continuity is preserved even when a finding hovers around the security peer floor between scans.
Rendering and exit codes#
logAndRender uploads the scan to the dashboard when authenticated (with a sanitized result and a letter grade: A at 90+, B at 75+, C at 50+, D at 25+, F below), then renders in the requested format: html (default), terminal, json, csv, or docx.
The HTML path splits on auth state. Unauthenticated scans serve the full report on http://localhost:<port> (port is 4173 plus a random offset under 100) with a 10-minute auto-close timer, so a first-run user gets a real report with zero account friction. Authenticated scans print a concise summary and link the dashboard copy; a local HTML file is written only when the upload failed or --output was given.
--format json keeps stdout machine-clean: the result JSON is the only thing on stdout, and every notice goes to stderr. This is what makes vibedrift --json | jq .compositeScore reliable in CI. Finally, --fail-on-score <n> calls process.exit(1) when the composite score is below the threshold, which is the entire CI integration surface.
The CLI command surface#
The program is built with Commander.js in src/cli/index.ts. There are 16 commands; scan is the default (registered with isDefault: true), so vibedrift . and plain vibedrift both scan.
| Command | What it does | ||
|---|---|---|---|
scan [path] | Full local scan of a project; the default command | ||
init [path] | Guided one-time setup: writes .vibedriftignore and .vibedrift/config.json | ||
ignore <patterns...> | Appends glob(s) to .vibedriftignore (idempotent) | ||
watch [path] | Reruns the scan and refreshes .vibedrift/ outputs on file changes; --interval debounce, default 10s; local-only | ||
| `telemetry <enable\ | disable>` | Flips the anonymous-beacon opt-out in ~/.vibedrift/config.json | |
login | Device auth flow; stores the bearer token | ||
logout | Logs out and revokes the current token | ||
status | Shows the current account, plan, and token | ||
usage | Shows the current billing period's scan usage | ||
upgrade | Opens the pricing page | ||
billing | Opens the Stripe Customer Portal | ||
doctor | Diagnoses installation, auth, and API connectivity | ||
update | Self-updates the CLI | ||
feedback [message...] | Sends feedback to the maintainer (interactive when no message given) | ||
| `hook <install\ | uninstall\ | status>` | Manages a git pre-push hook that blocks pushes below a drift-score threshold (default 70) |
mcp | Runs the long-lived stdio MCP server; stdout is the JSON-RPC channel, logs go to stderr |
The scan command carries the bulk of the flags: output (--format, --output, --json, --fail-on-score), analysis toggles (--no-codedna, --no-cache, --deep, --local-only), file filtering (--include, --exclude, both repeatable, and --diff [ref]), context emission (--write-context, --inject-context), identity (--project-name, --private), diffing (--compare, --no-compare, --since), plus --verbose and the hidden --api-url override.
Environment variables: VIBEDRIFT_TOKEN (overrides the stored token), VIBEDRIFT_API_URL, VIBEDRIFT_NO_BROWSER, and VIBEDRIFT_TELEMETRY_DISABLED=1 (equivalent to vibedrift telemetry disable).
Configuration: files and precedence#
VibeDrift has exactly two project-level files and one user-level file, with distinct jobs.
.vibedrift/config.json (project, meant to be committed; src/core/project-config.ts) holds behavior: version (schema version, currently 1), format (default report format), failOnScore (a CI score floor that applies even without the flag), and security.allowlist (gitignore-style globs; a matching route is excluded from the security dominance vote entirely, the same suppression the inline @vibedrift-public annotation gives a single route, declared once for a whole directory). normalizeProjectConfig drops unknown or out-of-range fields rather than erroring, so an old CLI reading a newer config degrades gracefully. vibedrift init creates it.
Precedence is: explicit CLI flag, then project config, then built-in default. The wiring distinguishes a real --format html from Commander's built-in default via command.getOptionValueSource("format") === "cli", so the project config only fills in values the user did not state.
.vibedriftignore (project; src/utils/vibedriftignore.ts) uses gitignore syntax and is honored by both the scan and the MCP server, because both go through the same loadGitignore helper that layers .gitignore then .vibedriftignore. The scan actively nudges toward it: when at least 5 scanned files sit under fixture-like paths (fixtures/, __mocks__/, snapshots/, generated/, or *.generated.* names), it prints a ready-to-paste vibedrift ignore ... suggestion (never in --format json mode).
~/.vibedrift/config.json (user; src/auth/config.ts) holds the auth token, telemetry preference, and cached plan. It is deliberately separate: a project config is shared with a team via git, and secrets and personal preferences must never travel with it.
--include/--exclude use a small dependency-free glob dialect (*, **, ?, {a,b}, [abc], trailing /) matched against the relative path. It does not support ! negation; use .vibedriftignore for durable exclusions and the flags for one-off slicing.
--local-only is the single switch that gates every network touch: the auth banner, deep scan, dashboard upload, fix-prompt synthesis, the anonymous beacon, and the update check. It also forces deep: false even if --deep was passed. If you are auditing what the CLI sends, this is the flag to test against.
docs/handbook/03-scan-pipeline.md, then rebuild with node scripts/build-handbook.mjsLayer 1: Static Analyzers#
The 13 static analyzers are the free, local, always-on layer of a scan. Each one reads the full AnalysisContext (all files, ASTs, manifests) and returns a list of findings. They are deliberately conservative: an analyzer that cries wolf gets ignored, so nearly every one aggregates related evidence into a single finding, applies minimum-count thresholds before emitting anything, and carries an explicit confidence value that the scoring engine uses to discount uncertain signals.
This chapter covers how analyzers plug into the pipeline, the shape of a finding, and then each analyzer: what it detects, the algorithm and thresholds, and a real example of code it flags.
Registration, caching, and the drift/hygiene split#
An analyzer implements the Analyzer interface (src/analyzers/base.ts):
// src/analyzers/base.ts
export interface Analyzer {
id: string;
name: string;
category: ScoringCategory; // which of the 5 score buckets it feeds
requiresAST: boolean;
applicableLanguages: SupportedLanguage[] | "all";
version?: number; // cache-invalidation knob, defaults to 1
analyze(ctx: AnalysisContext): Promise<Finding[]>;
}createAnalyzerRegistry() (src/analyzers/index.ts) returns the 13 instances in a fixed declaration order. runAnalyzers executes them concurrently and reassembles findings in that order, so output is deterministic. Each analyzer's results are cached under a key derived from its id, its version, and the content hashes of the files it applies to: edit one Python file and the JS/TS-only imports analyzer still replays from cache (an analyzer declaring applicableLanguages: "all", like language-specific, keys on every file and re-runs). When you change an analyzer's logic, bump its version or stale cached findings will keep appearing until the TTL expires.
One classification matters more than any other here. Every analyzer id is assigned a kind in src/scoring/categories.ts: drift (grounded in the repo's own dominant pattern) or hygiene (a generic check any linter could make, no repo baseline). Among the 13 static analyzers only naming and imports are drift-kind. The other 11 feed the parallel Hygiene Score and never move the headline Vibe Drift Score. This is the product identity enforced in code: VibeDrift measures drift, and a generic empty-catch finding, however real, is not drift.
Unknown analyzer ids default to hygiene (getAnalyzerKind in src/scoring/categories.ts). If you add an analyzer and expect it to affect the headline score, you must register it as drift-kind in CATEGORY_CONFIG; forgetting this fails safe (the score ignores it) but silently.
The Finding shape#
Every analyzer, drift detector, and Code DNA module emits the same structure (src/core/types.ts):
// src/core/types.ts (abridged)
export interface Finding {
analyzerId: string;
severity: "info" | "warning" | "error";
confidence: number; // 0.0-1.0
message: string;
locations: FileLocation[];
tags: string[];
consistencyImpact?: number; // score gain if resolved; set by computeScores
driftSignal?: { consistencyScore: number; dominantCount: number; totalRelevantFiles: number };
dupGroupSize?: number; // for grouped duplicate findings
}severity and confidence are independent axes and both feed scoring: severity says how bad the pattern is if real, confidence says how sure the analyzer is that it is real. Static analyzers never set driftSignal (that field is populated by driftFindingToFinding for cross-file detectors); its absence is how the scoring engine knows to use count-based rather than dominance-based magnitude for a detector group.
The examples below draw on the repo's own end-to-end fixture at test/fixtures/messy-project/, a deliberately messy Express app that most of these analyzers flag.
Convention analyzers (drift-kind)#
naming: Naming Conventions#
Detects a codebase split between identifier conventions (camelCase vs snake_case), gated by entropy so that a genuinely mixed codebase is reported as "no convention" rather than as hundreds of deviations.
The algorithm (src/analyzers/naming.ts, v2): extract declared identifiers by walking declarator AST nodes when a tree exists, falling back to a regex over const|let|var|function|def|func|fn otherwise. Identifiers of length 1 or starting with _ are skipped, and PascalCase and SCREAMING_SNAKE are excluded from the vote (they legitimately coexist with either convention for types and constants). Then compute Shannon entropy over the convention counts, normalized by log2(k):
normalizedH > 0.8: one info finding, "No dominant naming convention", confidence 0.75. There is no majority to deviate from, so flagging individual files would be noise.- Otherwise: flag each minority convention used in at least 2 unique files as a warning, with confidence
clamp(1 - H, 0.3, 0.9). The tighter the dominant convention, the more confident the analyzer is that a deviation is real drift.
Example from the fixture: src/data.ts declares fetchData, getData, processData (camelCase) while src/users.ts declares get_users, get_user_by_id, fetch_user_data (snake_case). That mix trips the entropy gate, so the run emits the single info finding at confidence 0.75 reporting no dominant naming convention, counting camelCase in 4 files against snake_case in 3; the minority-deviation warning never fires here, because there is no majority to deviate from.
imports: Import Patterns#
Detects ESM/CommonJS mixing in JS/TS projects, with enough context awareness to avoid the classic false positive: require("fs") or require("node:stream") for Node builtins is a legitimate pattern inside ESM and is not drift, while require("./utils") or require("lodash") in an otherwise-ESM project is (src/analyzers/imports.ts, v2).
Config files (which routinely must be CJS) are skipped via a filename pattern, fixture paths are skipped, and comments, template literals, and regex literals are stripped before matching so a require( inside a string cannot fire. Two finding shapes come out: a per-file "Mixed ESM and non-builtin CommonJS" warning at confidence 0.9, and a project-level "Mixed ESM/CommonJS across project: N ESM, M CJS" warning at confidence 0.85 listing the minority-side files.
Example: the fixture's src/app.js opens with const express = require('express'); and ends with module.exports = app; while its sibling .ts files use import/export. Only the project-level finding fires, as Mixed ESM/CommonJS across project: 3 ESM files, 1 CJS files. The per-file shape needs ESM and CommonJS inside the same file, and src/app.js is pure CommonJS: module.exports does not match the ESM pattern, which looks for import/export statements.
Robustness analyzers (hygiene)#
error-handling: Error Handling#
Two independent checks for JS/TS (src/analyzers/error-handling.ts):
- Empty catch blocks, matched by the regex
catch\s*\([^)]*\)\s*\{\s*\}. All hits aggregate into one finding: severity error when the count exceeds 5, warning otherwise, confidence 0.95 (an empty catch is nearly always exactly what it looks like). - Unhandled async: find
asyncfunction headers, extract bodies by brace-depth counting, and flag bodies thatawaitwithout any oftry,.catch(,catch(, or aResult/Eithertype mention. Emitted per directory, and only when a directory accumulates more than 3 such functions (info, confidence 0.6). The directory threshold exists because a single fire-and-forget await is often intentional; a directory full of them is a habit.
Example: src/data.ts in the fixture ends with export async function processData(items) { try { ... } catch (e) {} }, an empty catch that lands in the aggregated finding.
This analyzer is JS/TS only because empty catch and un-awaited async are JavaScript shapes. Error handling for Go, Python, and Rust is not absent; it lives in language-specific below (Go unchecked err, Python bare except, Rust .unwrap() overuse), which tags those findings error-handling too.
language-specific: Language-Specific Patterns#
A grab bag of per-language idiom checks that do not fit the shared analyzers, including the error-handling checks for Go, Python, and Rust (src/analyzers/language-specific.ts):
- Go: unchecked
errassignments, flagged when the next non-comment line neither mentionserrnor starts withreturn(warning, escalating to error above 10 hits, confidence 0.7); goroutines launched with noctx/context.within 2 lines (warning, 0.6);.Lock()with nodefer .Unlock()in the next 3 lines (warning, 0.75, "risk of deadlock"). - Python: bare
except:(error, confidence 0.95); mutable default arguments=[],={},=set()(warning, 0.9). - Rust: more than 2
.unwrap()calls (warning, error above 20, confidence 0.8);unsafe {blocks (warning, error above 5, confidence 0.85).
Example: def load(items=[]): is flagged as a mutable default argument, one of the oldest Python footguns (the list is shared across calls).
Redundancy analyzers (hygiene)#
duplicates: Code Duplication#
Finds near-identical function bodies across files. It is a thin wrapper over the shared MinHash pipeline in src/codedna/minhash.ts: tokenize, normalize (preserving call targets so fetchUsers() and fetchOrders() do not collapse into the same body), shingle with k=5, hash into 128-permutation MinHash signatures, band into an LSH index (16 bands of 8 rows), then verify candidate pairs with an LCS similarity check. The banding math makes candidate lookup near-linear instead of O(n²) over all function pairs; the LCS verification step is what keeps precision high.
Thresholds (src/analyzers/duplicates.ts, v3): function bodies under 20 characters or 15 normalized tokens are skipped (too short to be meaningful duplicates), candidate pairs must be cross-file with a token-length ratio of at least 0.6, and only pairs with LCS similarity at or above FLAG_THRESHOLD = 0.7 are flagged. One aggregated finding: error above 5 unique pairs, warning otherwise, confidence 0.75, with "% similar" snippets in the locations.
Example of the cross-file rule at work: the fixture's fetchData() and getData() in src/data.ts are byte-identical fetch wrappers, yet this analyzer emits nothing on the fixture, because both live in the same file and same-file candidate pairs are skipped (if (a.file === b.file) continue; in duplicates.ts). Paste either function into a second file and the pair gets flagged.
// test/fixtures/messy-project/src/data.ts
export function fetchData() {
return fetch('/api/data').then(r => r.json());
}
export function getData() {
return fetch('/api/data').then(r => r.json());
}todo-density: TODO/FIXME Density#
Counting TODOs is easy; deciding how many is too many for this repo is the actual problem. This analyzer (src/analyzers/todo-density.ts, v2) matches \b(TODO|FIXME|HACK|XXX|TEMP)\b and then applies a per-file Poisson outlier test: with λ = total TODO count divided by file count as the expected rate, a file with k TODOs is flagged only when P(X >= k | λ) < 0.05. A repo that is uniformly 2-TODOs-per-file flags nothing; the one file with 12 while the rest have none gets flagged. Requires at least 3 files (no meaningful baseline below that); severity error when the file has 10 or more TODOs; confidence 1.0 (the count is a fact, not an inference).
A second signal escalates TODOs sitting within 5 lines of stub-shaped code (placeholder returns, raise NotImplementedError, unimplemented!(, Go panic("not implemented"): those produce a separate finding at confidence 0.95, warning (error at 3 or more hits), because a TODO next to a stub marks unfinished shipped behavior rather than a note-to-self. A project-level info summary with density per 1000 lines is emitted whenever the repo contains at least one marker; a zero-TODO repo produces no findings at all.
Example: the fixture's src/app.js has three consecutive marker lines near the top (// TODO: fix this before launch, // FIXME: this is a hack, // HACK: temporary workaround), yet the only finding the fixture produces is the info summary, 9 TODOs/FIXMEs across 3 files (density: 81.1/1K lines). The markers are spread evenly enough that no single file's count clears the P < 0.05 outlier test against the repo-wide rate, which is exactly the discipline the Poisson test enforces: a repo-wide habit is reported as a rate, not as per-file findings.
dead-code: Dead Code Detection#
For JS/TS this analyzer builds a real import graph (buildImportGraph, src/core/import-graph.ts) rather than grepping, and reports at two granularities (src/analyzers/dead-code.ts, v7, the highest version number in the registry, which reflects how much false-positive history this one has absorbed):
- Symbol-level: exported names that no file imports and that appear at most once in their own file (the declaration itself). Emitted only when more than 3 dead exports exist; error above 10; confidence 0.8.
- File-level: files with zero incoming imports, minus a long exclusion list learned from real repos: entry-point basenames (
index,main,app,server,mod,lib,init,__init__,setup,config,routes,handler(s),cli), test/config/.d.ts/.worker.patterns, and worker runtime roots referenced vianew Worker('X')orgetURL('X')strings. Warning above 5 orphans, else info, confidence 0.85.
Go and Python use simpler whole-corpus occurrence counting (a symbol appearing at most once is presumed dead), at accordingly lower confidences of 0.55 and 0.5, with Python _private definitions skipped. A separate unreachable-code check flags a line following a complete return/throw/break/continue at the same or deeper indent (warning, confidence 0.65).
Example: export function retrieveData(id) in the fixture's src/data.ts is exported but never imported by any other fixture file, so it lands in the dead-exports finding.
Dependency and configuration analyzers (hygiene)#
dependencies: Dependency Health#
Cross-references what a manifest declares against what the code imports, in both directions, for all four ecosystems (src/analyzers/dependencies.ts, v2).
For JS/TS: declared means dependencies, devDependencies, peerDependencies, optionalDependencies, plus the package's own name; imports are collected from the AST when a tree exists, regex otherwise. The interesting engineering is in the false-positive suppression for phantom dependencies (declared, never imported): dev-tool names (linters, bundlers, type packages) are excluded by pattern, and build-config references count as usage, so a webpack loader named only as a string in webpack.config.js is not phantom. Error above 5 phantoms, confidence 0.75. For missing dependencies (imported, not declared): path-alias and virtual-module imports are skipped, and detected monorepos drop severity to warning and confidence to 0.4, because the package is usually declared in a sibling workspace manifest the analyzer is not looking at.
Go compares go.mod requires against imports (stdlib detected by the absence of a dot anywhere in the import path, module-internal imports skipped); Python compares requirements against import/from statements with 40+ stdlib names excluded and flags phantoms only above 2; Rust compares Cargo dependencies against use statements with - to _ normalization.
Example: the fixture's package.json declares moment, unused-package, and another-unused, none of which any source file imports. All three land in one phantom-dependencies finding, but the emitted message is 4 phantom dependencies (declared but unused): moment, unused-package, another-unused, messy-project: the analyzer adds the package's own name to the declared set (so self-imports are not reported as missing), and the phantom check does not exclude that entry, so the self-name is counted too.
config-drift: Config Drift#
Detects environment variables drifting out of sync with .env.example (src/analyzers/config-drift.ts, v2). It knows the access pattern for every supported language: process.env.X and import.meta.env.X, os.environ.get("X") (the call form only; the bracket subscript os.environ["X"] is not matched), os.Getenv("X"), env::var("X").
Three findings: a variable read in code but missing from .env.example (warning per variable, confidence 0.85, NODE_* skipped); a variable declared in .env.example that no code reads (single info, 0.7); and no .env.example at all while the code reads more than 3 env vars (info, 0.7). The direction matters: a missing example entry breaks the next developer's onboarding, a stale one merely lies, and the analyzer weights them accordingly.
Example: the fixture's src/app.js reads process.env.SESSION_SECRET and process.env.REDIS_URL, but its .env.example declares only DATABASE_URL and API_KEY. Both variables get flagged as undocumented.
Security (hygiene)#
security: Security Posture#
Twenty regex rules in SECURITY_PATTERNS (src/analyzers/security.ts, v3) covering hardcoded secrets (API keys, tokens, -----BEGIN ... PRIVATE KEY----- blocks, AWS (AKIA|ASIA)[A-Z0-9]{16} key ids), injection (template-literal SQL in JS, fmt.Sprintf inside db.Query in Go, shell=True in Python), unsafe sinks (eval(, new Function(, innerHTML =, dangerouslySetInnerHTML), weak crypto (md5, sha1, Math.random() near security-sensitive context), path traversal, SSRF, pickle.load, unsafe yaml.load, Go InsecureSkipVerify: true, and Rust unsafe {.
Not all 20 rules are equal, and the rule metadata encodes that:
floor: true(5 rules: hardcoded-api-key, hardcoded-token, private-key, aws-key, go-tls-skip-verify) emit under the distinct analyzer idsecurity-floor. These are the near-zero-false-positive rules; the separate id lets the report render a high-precision badge. Still hygiene-kind: even a certain hardcoded key is a hygiene fact, not drift.demoted: true(5 rules: innerHTML-assignment, math-random-crypto, path-traversal, ssrf-risk, rust-unsafe) are forced to severityinfoand taggeddemoted, because their regexes catch too many legitimate uses to warrant a warning.
Regex security scanning lives or dies on false-positive control, so four mechanisms stack: per-rule negativeFilter patterns (a "key" containing example|placeholder|test|dummy does not fire), contextRequired proximity checks (Math.random() only fires with token/secret/password language within 5 lines), a guard so that pattern-definition lines in analyzer-like code do not flag themselves, and a full skip of fixture and test paths. A single pre-filter regex (SECURITY_PREFILTER) skips files containing no security keywords at all, so the 20-rule sweep only runs where it could match.
When multiple rules hit the same file:line, their confidences combine by naive-Bayes log-odds rather than max: odds = Π c_i / (1 - c_i) with each c_i clamped to [0.01, 0.99], and combined confidence min(0.999, odds / (1 + odds)). Three corroborating hits at 0.75/0.80/0.95 combine to about 0.996, sharper than any one alone, and the message is annotated with how many patterns corroborate.
Example: const apiKey = "sk_live_abcdef1234567890abcd" fires hardcoded-api-key as an error under security-floor.
Clarity analyzers (hygiene)#
intent-clarity: Intent Clarity#
Five sub-checks that approximate the question "can a newcomer tell what this code is for?" (src/analyzers/intent-clarity.ts, v2):
- Commented-out code: 3 or more consecutive comment lines containing code tokens. Severity ramps info, warning above 3 blocks, error above 10; confidence 0.7.
- Generic names: a hardcoded set (
data,temp,tmp,val,manager,helper,utils,misc, and friends) plus a corpus-derived set: identifiers of 8 characters or fewer appearing in at least 30% of files (minimum 3) in projects of 10+ files. The corpus set matters because "generic" is repo-relative; a name carrying no information in this codebase is generic here even if it is not on anyone's list. Warning above 3 generic function names (confidence 0.65); very short names (under 3 chars, withgo,fn,ok,idexempt) only above 5 (info, 0.5). - Long functions: over 50 lines flags, any over 100 lines escalates the aggregate finding to error; confidence 0.85.
- Low documentation: files of 100+ lines with under 5% comment density (flagged above 2 such files), and undocumented exported functions (Go capitalized, Python public, Rust
pub fn) above 10; confidences 0.6 and 0.55. - Verb/AST mismatch: a function's leading verb makes a promise its body must keep.
get/find/fetchmust return non-void,validate/checkmust throw or return a boolean,is/hasmust return a boolean,delete/removemust mutate something. Bodies under 40 characters are skipped (stubs and one-liners tell you nothing). Aggregated, warning above 10, confidence 0.6.
The fixture produces no intent-clarity finding, and the misses are instructive: generic-name matching is exact on the whole lowercased name, so processData does not match the set entries process or data; the corpus-derived set only activates in projects of 10 or more files (the fixture has 4); and the generic-name warning needs more than 3 generic function names before it emits. A project with several functions literally named process or handle is what trips this check.
complexity: Code Complexity#
Sonar-style cognitive complexity, not McCabe cyclomatic complexity (src/analyzers/complexity.ts, v3). The distinction is the point: McCabe counts branches, cognitive complexity counts what a reader must hold in their head. Each flow break costs +1; nesting constructs cost an additional +nestingLevel; else/elif walk at the same level as their if (they do not deepen); each &&/||/and/or adds a flat +1. Per-language AST node sets cover all five languages, with a brace-depth regex fallback for unparsed files. The practical consequence: a 3-condition if-pyramid scores 6 against 3 for its guard-clause-refactored equivalent (the worked pair in the file header), and the gap widens with every extra nesting level, which is exactly the refactor the finding should motivate.
Tiers: CC above 15 is an error (confidence 0.9), above 10 a warning (0.75), above 6 an info (0.5). Per-tier caps (30/30/20 individual findings) roll the tail into one rollup finding at confidence reduced by 0.2, so a huge legacy repo produces a bounded report instead of 400 near-identical findings. A project-level signal fires when the p90 complexity exceeds 10 (warning) or the median exceeds 6 (info), separating "a few hot spots" from "systemically dense."
implementation-gap: Implementation Gap#
Detects code that claims to work but does not: the analyzer was motivated by a real shipped stub in the VibeDrift API itself, where an endpoint returned a hardcoded "unvalidated" verdict (the header comment in src/analyzers/implementation-gap.ts, v1, tells the story). Three signals:
- Placeholder string returns:
return "<literal>"where the trimmed, lowercased literal is inPLACEHOLDER_PHRASES(unvalidated,unimplemented,not implemented,todo,tbd,placeholder,stub,fake,dummy,mock,wip,coming soon, and similar). - Placeholder field assignments (
verdict="unvalidated"in kwargs, dicts, or object literals), capped at 3 hits per file. - Explicit not-implemented markers:
raise NotImplementedError,throw new Error("Not implemented..."), Gopanic("not implemented"), Rustunimplemented!()andtodo!().
Markers are errors at confidence 0.95 (the code announces its own gap); placeholder returns and fields are warnings, escalating to error at 3 or more, at confidence 0.75. The analyzer itself scans every file; hits in test and fixture code are instead de-weighted downstream by the scoring engine's file-importance weights (see the scoring chapter).
Example flagged: return "placeholder"; inside a production request handler.
Summary table#
| Analyzer | Kind | Category | Version | Languages | Key threshold |
|---|---|---|---|---|---|
naming | drift | Architectural Consistency | 2 | all | entropy gate 0.8; minority in 2+ files |
imports | drift | Architectural Consistency | 2 | JS/TS | non-builtin require in ESM |
error-handling | hygiene | Architectural Consistency | 1 | JS/TS (Go, Python, Rust error handling is in language-specific) | error above 5 empty catches |
language-specific | hygiene | Architectural Consistency | 1 | all (Go/Py/Rust checks) | per-language error handling and idioms; e.g. Rust over 2 .unwrap() |
duplicates | hygiene | Redundancy | 3 | all | LCS similarity 0.7 or above |
todo-density | hygiene | Redundancy | 2 | all | Poisson P < 0.05 per file |
dead-code | hygiene | Redundancy | 7 | all | over 3 dead exports |
dependencies | hygiene | Dependency Health | 2 | all | error above 5 phantoms |
config-drift | hygiene | Dependency Health | 2 | all | per missing env var |
security (+ security-floor) | hygiene | Security Consistency | 3 | all | 20 rules; 5 floor, 5 demoted |
intent-clarity | hygiene | Intent Clarity | 2 | all | 50/100-line functions; generic names |
complexity | hygiene | Intent Clarity | 3 | all | CC 6/10/15 tiers |
implementation-gap | hygiene | Intent Clarity | 1 | all | markers at 0.95 confidence |
The Languages column is where each analyzer's checks actually fire, not just where its files parse. error-handling is JS/TS because its patterns (empty catch, un-awaited async) are JavaScript shapes; the equivalent error-handling checks for Go, Python, and Rust live in language-specific. Import analysis (imports, and the import-consistency drift detector) is JS/TS only; there is no import-style or convention check for Python, Go, or Rust yet.
docs/handbook/04-static-analyzers.md, then rebuild with node scripts/build-handbook.mjsCross-File Drift Detection#
Single-file analyzers can tell you a function is too long. They cannot tell you that this file handles errors differently from every one of its neighbors. That second kind of signal, a file disagreeing with its own codebase, is what VibeDrift calls drift, and it is what the cross-file drift detectors measure.
createDriftDetectors() in src/drift/index.ts registers 14 detectors. Eight of them are the canonical set this chapter walks through in detail: architectural consistency, naming conventions, security posture, semantic duplication, phantom scaffolding, import style, export style, and async patterns. The remaining six reuse the same machinery and are listed briefly at the end. Security posture is registered here like any other detector, but its internals are deep enough that it gets its own chapter (the next one).
Every detector implements the same small contract from src/drift/types.ts:
// src/drift/types.ts
export interface DriftDetector {
id: string;
name: string;
category: DriftCategory;
detect(ctx: DriftContext): DriftFinding[];
}A DriftFinding carries the vote outcome, not just a complaint: dominantPattern (what the codebase does), dominantCount and totalRelevantFiles (the vote tally), consistencyScore (dominant over total, times 100), deviatingFiles (who broke the convention and how), and up to three dominantFiles exemplars a fix can copy from. Detectors that count phenomena rather than vote (duplicate pairs, dead exports) set countBased: true so the scoring engine treats them differently, as described below.
The dominance vote#
The backbone of every detector is the dominance vote, documented at the top of src/drift/utils.ts: profile each file's pattern, aggregate the primary patterns into a distribution, pick the majority as the baseline, and flag the minority as deviators. Drift is always measured against the repo's own behavior, never against an external style guide.
There are two scoping modes, and each detector picks one per axis:
- Project-scoped:
buildPatternDistributionplusfindDominantPatternpluscollectDeviatingFilesover the whole file set. Used when a convention should hold everywhere (symbol naming is the main example: readers should not have to context-switch per directory). - Directory-scoped:
buildDirectoryScopedVotegroups files by their enclosing directory and votes inside each group. This is the sharper, lower false-positive mode, because a minority directory with an internally consistent convention is not drift. A legacyutils/on.then()chains next to a newhandlers/on async/await should produce zero findings.
The directory-scoped algorithm, from src/drift/utils.ts:
- Group file profiles by directory.
- Skip any group smaller than
minGroupSize(default 3): two files cannot outvote each other meaningfully. - Skip a group with fewer than 2 distinct patterns: everyone agrees.
- Pick the dominant pattern. Skip the group if its share is below
dominanceThreshold(default 0.7): without a strong majority there is no convention to deviate from. - Emit the minority files as deviators.
Both thresholds are per-call options, but every canonical detector that runs the directory vote passes minGroupSize: 3 and dominanceThreshold: 0.7. (Three of the eight canonical detectors never call it: security posture runs its own ratio vote, and semantic duplication and phantom scaffolding are count-based, with no vote at all.) Iteration order is deterministic (directories sorted ascending) so the same repo always yields the same findings in the same order.
Before a vote runs, most detectors filter the file set through isAnalyzableSource (src/drift/utils.ts): tests, specs, mocks, fixtures, config files, .d.ts declarations, node_modules, dist, and build output never vote and never get flagged. A convention in test scaffolding is not evidence about production code. Two exceptions apply their own filtering instead: the project-wide symbol-naming vote iterates every file, and phantom scaffolding works from the import graph.
The entropy gate#
A vote assumes a convention exists. When a project genuinely has no convention on some axis (four import styles, evenly split), flagging the "minority" would scold arbitrary files for deviating from an arbitrary plurality. The entropy gate (entropyGate in src/drift/utils.ts) decides which situation you are in.
It computes the normalized Shannon entropy of the pattern distribution: H / log2(k) for k distinct patterns, which is 0 for a unanimous distribution and 1 for a perfectly even split. If normalized entropy exceeds 0.8, the decision is no_convention with confidence 0.75, and the detector emits a single category-level finding instead of per-file flags. Otherwise the decision is flag_deviators with confidence clamp(1 - H_norm, 0.3, 0.9): the tighter the convention, the more confident the detector is that a deviation is real drift.
The no_convention finding (noConventionFinding) is not a free pass. For a self-consistency score, "no dominant pattern" is the floor of consistency, not the absence of a signal, so the finding's consistencyScore is the plurality share times 100 (a smooth measure of chaos depth, deliberately not the saturating entropy value, so progressively more mixed repos score monotonically lower). It names no deviating files, because there is no majority to deviate from, and it only fires with at least MIN_NO_CONVENTION_FILES = 5 files on the axis: a 1-vs-1 split is sparse data, not chaos.
Temporal decay#
When git metadata is available, a file's vote is weighted by how recently it was touched (temporalWeight in src/drift/utils.ts):
weight = 2.0 * e^(-ln(2) * daysAgo / 90)A just-touched file votes at 2x, a 90-day-old file at 1x, a 180-day-old file at 0.5x, and a year-old file at roughly 0.12x. The point is to let three recent files outvote ten old ones when the codebase is actively migrating away from an old pattern: the convention you are moving toward should win the vote even before it wins the file count. Files with no git metadata weigh 1.0, so repos scanned without history behave exactly as before temporal weighting existed (buildFileAgeMap returns undefined and the whole mechanism switches off).
A separate post-pass, detectPivotsAcrossFindings (src/drift/pivot-detector.ts, invoked from src/drift/index.ts), goes one step further: when the recent files decisively use pattern B while older files use pattern A, deviators aligned with A are reclassified as legacy migration candidates rather than drift. Being on the old side of a deliberate migration is a different problem from breaking a live convention, and the report says so.
Intent seeding and the anti-laundering rule#
Teams declare conventions in CLAUDE.md and AGENTS.md files. VibeDrift parses these into intent hints, and pickIntentHint (src/drift/utils.ts) selects the strongest hint per category, ignoring anything below a 0.6 confidence floor.
seedDominanceVote folds the hint into the vote. If the declared pattern already appears in the distribution, its weight is multiplied by intentBoost (1.5x). If it does not appear at all, a virtual entry is injected with weight 1 + hint.confidence, so a high-confidence declaration carries roughly the weight of two files: strong enough to flip a close vote, not strong enough to override a broad consensus. Inside directory-scoped votes the boost is applied after temporal weighting, and a seeded vote also skips the 0.7 dominance threshold, because the declaration itself is a signal worth reporting whether or not the code has converged. A directory that is unanimous on the wrong pattern (unanimity would normally mean "skip, no drift") still emits a vote entry when a hint declares something else: the whole directory ignoring the declaration is exactly the finding.
The anti-laundering rule: declaredMatched is computed from the raw, unboosted code dominant, never from the boosted result. If the declaration's 1.5x boost is what flipped the vote, the code does not actually follow the declaration, and reporting "matched" would launder an unconverged split into a green checkmark. A boost-driven flip therefore still reports divergence, and the flipped field marks it as the strongest possible evidence that the codebase has not converged.
A second post-pass in src/drift/index.ts stamps intentDivergence provenance onto findings where the declared pattern and the voted dominant disagree, so renderers can show "you declared X, the code does Y" with the declaration's source file and line.
From finding to score#
Drift findings feed the composite Vibe Drift Score through a strict, typed pipeline in src/drift/index.ts and src/scoring/engine.ts.
driftFindingToFinding converts each DriftFinding into the standard Finding shape with analyzerId = "drift-<driftCategory>", keyed off the typed DriftCategory enum rather than the detector's freeform detector string. That distinction matters: keying off the freeform string was, per the comment in src/drift/index.ts, the root of a wiring bug that silently excluded 11 of 14 detectors from the score. The one exception is the security suppression audit, which maps to the hygiene analyzer id security-suppression instead.
src/scoring/categories.ts registers every drift-* analyzer id as kind "drift", and only drift-kind findings feed the composite. security_posture-advisory and security-suppression are registered as hygiene: they render in reports but can never move the score.
Vote-based findings carry a driftSignal { consistencyScore, dominantCount, totalRelevantFiles } so the engine can weight damage by how inconsistent the category actually is: deviation for a dominance group is 1 - consistencyScore/100, a rate that is already size-normalized. Count-based findings (countBased: true) omit the signal and take the engine's density branch instead, which saturates on findings per function or per KLOC, because a raw pair count has no meaningful peer ratio.
Inside the engine, findings are grouped by analyzer and combined with a noisy-OR: health = product over detectors of (1 - damage), where each detector's damage is min(0.85, severity x confidence x importance x deviation x sampleConfidence). Severity maps to damage through SEVERITY_DAMAGE = { error: 0.7, warning: 0.4, info: 0.12 }, and sampleConfidence ramps up to 1.0 at SAMPLE_FULL_CONFIDENCE = 8 relevant files, so a 70 percent majority over 4 files carries half the damage weight of the same majority over 8.
The per-category report bars come from computeDriftScores (src/drift/index.ts): each category's bar is DRIFT_WEIGHTS[category] x categoryHealth(...), using the same noisy-OR the composite uses. This is a faithful decomposition of the composite, deliberately not a second formula. The display weights, from src/drift/types.ts:
| Category | Bar max | Category | Bar max |
|---|---|---|---|
| architectural_consistency | 16 | return_shape_consistency | 12 |
| security_posture | 14 | state_management_consistency | 10 |
| semantic_duplication | 14 | export_style | 10 |
| naming_conventions | 12 | async_patterns | 10 |
| phantom_scaffolding | 12 | logging_consistency | 8 |
| import_style | 12 | test_structure_consistency | 6 |
| comment_style_consistency | 5 |
The canonical detectors#
Each section below gives the drift definition, the algorithm, the thresholds, and a concrete before/after: code that gets flagged, the finding VibeDrift emits, and what alignment looks like.
Architectural consistency#
File: src/drift/architectural-contradiction.ts. Drift: a file whose directory peers use a different pattern on one of four axes: data access (repository, raw SQL, ORM, direct DB, HTTP client, in-memory), error handling (wrap with context, raw propagation, swallow, HTTP error response, exception throw, result type), config access, and dependency injection.
Algorithm: regex evidence extraction per axis per file (repository usage matches /\b(?:store|repo|repository)\.\w+\s*\(/g; raw SQL matches SELECT/INSERT/UPDATE/DELETE statements outside repository-named files), then one directory-scoped vote per axis with minGroupSize: 3 and dominanceThreshold: 0.7. It needs at least 3 profiled files overall. Files that show no signal on the data-access, error, or config axes are excluded from those votes entirely, but files with no DI signal get an explicit no_di sentinel, because "no dependency injection" is itself a pattern that can legitimately win a vote. A single intent hint seeds all four axes. Confidence 0.85; severity error at 3 or more deviators, warning below.
Before (in src/handlers/, where three sibling files go through a repository):
// src/handlers/reports.ts
export async function getReport(id: string) {
const rows = await db.query(`SELECT * FROM reports WHERE id = $1`, [id]);
return rows[0];
}VibeDrift reports:
src/handlers/: 1 file(s) use raw SQL queries while 3 use repository patternwith the recommendation "In src/handlers/, 3 of 4 files use repository pattern. Migrate deviating files for consistency."
After: return store.findReport(id);, matching the peers. The vote still runs but the group is unanimous, so no finding is emitted.
classifyDataAccessLabel is exported from this module so the MCP validate_change in-loop check speaks the identical pattern vocabulary; the batch scan and the in-loop check can never describe the same file in different words.
Naming conventions#
File: src/drift/convention-oscillation.ts. Drift: two distinct things. Symbol names (functions, classes, types) oscillating between conventions project-wide, because readers should not context-switch naming styles per directory, and file basenames deviating from their own directory's convention, which legitimately varies by subsystem.
Recognized conventions: camelCase, snake_case, PascalCase, SCREAMING_SNAKE, kebab-case. Idiomatic exceptions are filtered before the vote so they neither vote nor get flagged: classes are expected to be PascalCase in JS/TS and Python, Go exported identifiers are PascalCase with initialisms like HTTP and URL respected, Python dunders pass, and SCREAMING_SNAKE constants pass.
Thresholds: the symbol vote runs only with at least 5 symbols total, and fires per symbol type only when there are at least 3 symbols of that type, at least 2 conventions present, at least 3 deviants that are also at least 10 percent of the symbols, spread over at least 2 files. Confidence 0.8; severity error above 5 deviating files. The file-name vote is directory-scoped (3 / 0.7, temporally weighted, intent-seeded), confidence 0.75, warning at 3 or more deviators, else info. A single-token all-lowercase basename like index or render expresses no convention and is neutral.
Before (a project whose functions are camelCase in 40 places):
// src/services/report.ts
export function build_report_summary(rows: Row[]) { /* ... */ }With six such snake_case functions across two or more files, VibeDrift reports:
function naming convention oscillates: 40 use camelCase, 6 use other conventionsand the finding copy attributes the oscillation to likely coming from different AI sessions.
After: rename to buildReportSummary (deviants drop below the 3-and-10-percent gate, the finding disappears).
Security posture#
File: src/drift/security-consistency.ts, id security-consistency, category security_posture. Drift: a route missing a security property (authentication, input validation, rate limiting) that its peer routes have. This is the deepest detector in the codebase, with per-language AST extraction across JS/TS, Python, Go, and Rust and a strict never-false-bless invariant. It gets the whole next chapter; this chapter only notes that it plugs into the exact same vote-and-score pipeline as every other detector here.
Semantic duplication#
File: src/drift/semantic-duplication.ts. Drift: cross-file pairs of functions with near-identical normalized token streams: the rename-refactor case, where an AI session reimplemented formatCurrency as toUSD instead of importing it. The detector deliberately does not catch same-control-flow-different-API pairs: call targets are preserved during normalization, so two functions that do genuinely different work in the same shape stay distinct.
Algorithm: extract functions per file, build a MinHash signature per body, generate candidate pairs with LSH (locality-sensitive hashing, which buckets similar signatures so the detector never compares all pairs), verify each candidate with LCS (longest common subsequence) similarity over the normalized tokens, cluster with union-find, and roll findings up per directory.
Thresholds: flag at LCS similarity of at least FLAG_THRESHOLD = 0.7; bodies under MIN_BODY_TOKENS = 15 tokens are skipped (trivial getters would all match each other); a length-ratio pre-filter requires shorter/longer >= 0.6; same-file pairs are skipped (a file duplicating itself is a single-file concern). Severity is driven by duplicate strength, not count: error when max similarity is at least 0.95 and the cluster has at least 3 members, warning when max similarity is at least 0.85 or the cluster has at least 3 members, else info. Confidence 0.85, countBased: true.
Before:
// src/utils/currency.ts
export function formatCurrency(amount: number): string {
if (!Number.isFinite(amount)) return "$0.00";
const rounded = Math.round(amount * 100) / 100;
const sign = rounded < 0 ? "-" : "";
return `${sign}$${Math.abs(rounded).toFixed(2)}`;
}
// src/helpers/money.ts
export function toUSD(value: number): string {
if (!Number.isFinite(value)) return "$0.00";
const rounded = Math.round(value * 100) / 100;
const sign = rounded < 0 ? "-" : "";
return `${sign}$${Math.abs(rounded).toFixed(2)}`;
}VibeDrift reports, per directory:
src/utils/: 1 pair(s) of semantically duplicate functions detected (MinHash + LCS verified)with per-file evidence naming the partner: formatCurrency() reported as 100 percent similar to toUSD() at src/helpers/money.ts and its line number.
After: delete one implementation and import the other. The pair disappears from the candidate set entirely.
Phantom scaffolding#
File: src/drift/phantom-scaffolding.ts. Drift: exported CRUD-named handlers nobody uses. As the module header puts it, AI sessions love generating "complete" CRUD handlers that never get wired up.
Algorithm: build the JS/TS import graph (src/core/import-graph.ts), extract route registrations across all languages by regex (Go Echo .GET("/p", h), Gorilla HandleFunc, Express .get('/p', h), Flask/FastAPI decorators with the following def). An export is phantom if and only if all three hold: its name is CRUD-like (the isCrudLike regex matches prefixes from create, add, get, find, update, delete, remove through purge), it appears in no route table, and its file has zero incoming imports. Findings roll up per directory.
Thresholds: severity by dead share, the fraction of the directory's CRUD-like exports that are phantom: error at 0.5 or more, warning at 0.2 or more, else info; when the population is unknown the fallback is error at 8 or more phantoms and warning at 3 or more. Confidence 0.8, countBased: true. The import graph is JS/TS only; Go and Python phantoms are handled by the dead-code analyzer instead (src/analyzers/dead-code.ts), and Rust currently has no phantom or dead-export coverage at all.
Before:
// src/handlers/admin.ts (no file imports this module)
export async function deleteUser(id: string) { /* ... */ }If deleteUser never appears in any route registration, VibeDrift reports a phantom export for src/handlers/, with per-export evidence stating it has no incoming imports and sits in no route table.
After: either wire it up (router.delete('/users/:id', deleteUser) puts it in the route table) or delete it.
Import style#
File: src/drift/import-consistency.ts. Drift: a file whose local-import path style deviates from its directory peers: relative (./, ../) versus alias (@/, ~/). JS/TS only. import type lines are skipped (type-only imports vanish at build time and often follow tooling conventions), and external package imports are skipped because they have no path style choice to make.
Algorithm and thresholds: a file needs at least 3 local imports to be classified, and its majority style wins. The detector needs at least 3 profiled files. A project-level entropy gate runs first: high entropy emits one "no dominant import path style" finding instead of per-directory flags. Otherwise a directory-scoped vote runs at 3 / 0.7 with temporal weighting and intent seeding. Severity warning at 3 or more deviators, else info; confidence comes from the entropy gate.
Before (in src/routes/, where five peers import through the alias):
// src/routes/billing.ts
import { getInvoice } from "../services/invoices";
import { requireAuth } from "../middleware/auth";
import { log } from "../utils/log";VibeDrift reports:
Import path style in src/routes/: 5 files use path aliases (@/), 1 deviateAfter: switch the three imports to @/services/invoices, @/middleware/auth, @/utils/log.
Export style#
File: src/drift/export-consistency.ts. Drift: a file breaking its directory's export convention: default_export (any export default or module.exports =) versus named_only. Barrel and index.* files are excluded because re-exporting neighbors is their job, not a style choice.
Algorithm and thresholds: same pipeline as import style. At least 3 files, project entropy gate first, then a directory-scoped vote at 3 / 0.7. Severity warning at 3 or more deviators; confidence from the gate.
Before (in src/models/, four named-only peers):
// src/models/session.ts
export default class Session { /* ... */ }VibeDrift reports:
Export style in src/models/: 4 files use named exports only, 1 deviateand the recommendation notes that consistent named exports help tree-shaking.
After: export class Session { ... } and update the import sites.
Async patterns#
Files: src/drift/async-consistency.ts plus the shared classifier src/drift/async-style.ts. Drift: a file's async style deviating from its directory peers. The classification logic lives in its own module explicitly so the batch detector and the MCP validate_change tool can never disagree about what style a file uses.
classifyAsyncStyle returns null under 2 async operations (too little signal to classify), async_await when the await ratio exceeds 0.7, then_chains below 0.3, and mixed in between. Comment lines are excluded from both counts, and type/interface lines are additionally excluded from the .then( count, so a comment saying "we used to use .then here" cannot change a file's classification.
Algorithm and thresholds: JS/TS only, at least 3 profiled files, entropy gate first, then a directory-scoped vote at 3 / 0.7 with temporal weighting and intent seeding. Severity warning at 3 or more deviators; confidence from the gate. Directory scoping is the false-positive fix here: a legacy directory consistently on .then() chains is not flagged just because newer directories use async/await.
Before (in src/handlers/, four async/await peers):
// src/handlers/webhooks.ts
export function handleWebhook(req: Request) {
return verifySignature(req)
.then((ok) => (ok ? processEvent(req.body) : rejectEvent(req)))
.then((result) => ({ status: 200, result }));
}VibeDrift reports:
Async style in src/handlers/: 4 files use async/await, 1 deviateAfter: rewrite with async/await; the file's await ratio moves above 0.7 and it joins the majority.
The other six detectors#
The remaining registered detectors follow the same profile-vote-emit machinery:
- Return-shape consistency (
return-shape-consistency.ts): sibling handlers mixing error idioms, one throwing, one returning{ status: 404, error }, one returningnull. Each choice is valid alone; the drift is the mix. - Logging consistency (
logging-consistency.ts): mixed logger families in one project (console.log,winston.info,debug()), classified per file and put through the standard dominance vote. - Comment style consistency (
comment-style-consistency.ts): JSDoc in some files, plain//in others, none in a third set. Low signal by design: it emits a single project-summary finding atinfolevel rather than per-file flags. - State management consistency (
state-management-consistency.ts): mixing state strategies in React/Vue/Svelte projects (local hooks, context, Redux, Zustand, React Query, MobX, Pinia, Vuex). - Test structure consistency (
test-structure-consistency.ts): test files only; votes on test framework style (BDDdescribe/itversus flattest()) and mocking style (framework mocks, sinon, manual), project-scoped because test conventions usually span the whole project. - Commit archaeology (
commit-archaeology.ts): a git-history shape detector; a file whose authorship pattern stands out from the repo's norm (one author, one large burst, no refinement trail) is a drift signal about how the code was produced. It reports underarchitectural_consistency, which is why 14 detectors produce 13 score categories.
docs/handbook/05-drift-detection.md, then rebuild with node scripts/build-handbook.mjsSecurity Consistency: Auth Drift Across Languages#
The security posture detector is the deepest single subsystem in VibeDrift: a 697-line orchestrator (src/drift/security-consistency.ts) backed by four language-specific AST extractors (security-ast.ts for JS/TS, security-ast-python.ts, security-ast-go.ts, security-ast-rust.ts), a cross-file symbol index (security-xfile-index.ts), a suppression module (security-suppression.ts), and an in-loop twin for the MCP tools (route-auth-classify.ts). This chapter explains how it works and, more importantly, why it is built the way it is.
Philosophy: drift, not SAST#
VibeDrift does not ship a vulnerability scanner. It does not know CVEs, does not taint-track user input to a sink in this detector, and does not carry an opinion about whether your API should require authentication. What it measures is consistency: given how this repository protects its own routes, which routes fall short of that baseline?
The drift definition is: a route missing a security property (authentication, input validation, or rate limiting) that its peer routes have. If 8 of your 9 mutating routes go through requireAuth and one does not, that one route is either a bug or an undocumented exception, and either way a human should look at it. If none of your routes have auth and nothing suggests they should, the detector stays silent, because an intentionally public API is not drift.
This framing buys two things. First, near-zero configuration: the baseline is computed from the repo itself, so there is no rule pack to tune. Second, relevance: the finding "this route is unlike your other routes" is actionable in a way that generic checklist output is not. The cost is that the detector must be extremely careful about what it counts as "protected", which is where the rest of this chapter goes.
The three-phase model#
The orchestrator's header in src/drift/security-consistency.ts lays out three phases:
Phase 1: file-level middleware index. buildFileMiddlewareIndex scans each file once for middleware applied at the router or app level (router.use(requireAuth) in Express, e.Use(authMiddleware) in Echo, @app.before_request in Flask, app.add_middleware(AuthMW) in FastAPI) and records three boolean lanes per file: auth, validation, rate limit. AST extractors are used when a clean parse tree exists; regex fallbacks run otherwise. On a cleanly parsed Python, Go, or Rust file, the other languages' regex arms are forced off, because a hit in this index blesses the whole file: a Python docstring that happens to contain app.use(authMiddleware) must not mark every route in that file as authenticated through the JS regex.
Phase 2: inheritance. A route's effective protection is its per-route middleware union the file-level middleware. This kills the classic false positive where router.use(requireAuth) at the top of a file protects every handler below it while a proximity check sees no auth on any individual route. The Python, Go, and Rust AST paths do not use the flat file-level OR at all: they compute receiver- and scope-accurate inheritance from the tree, as described per language below.
Phase 3: dominance vote. For each property, per group of peer routes: if more than 75 percent of applicable routes have the property, flag the minority that does not.
Known limitation, stated in the module header: cross-file mount propagation is not resolved. If app.use('/api', apiRouter) lives in one file and the router in another, the mounting file's middleware does not propagate to the mounted router's routes. Resolving that would require a router import graph.
Route extraction per language#
Everything downstream depends on finding routes accurately. Each language has its own extractor with its own recognition rules.
| Language | Frameworks recognized | Extraction path |
|---|---|---|
| JS/TS | Express-style verb registrations; the middleware extractor's docs also name Hono, Fastify, and Koa, which share the .use(...) and receiver.verb('/path', ...) shapes | AST when a tree exists, regex fallback otherwise |
| Python | Flask (including Blueprints), FastAPI (including APIRouter), DRF @api_view function views | AST only on a clean parse; any parse error routes the whole file to the legacy regex |
| Go | Gin, Echo, chi, Gorilla mux, stdlib net/http (including Go 1.22 verb-in-pattern strings) | AST only on a clean parse; parse errors fall back to regex |
| Rust | Axum (route builder), Actix Web and Rocket (attribute macros) | AST only; there is no Rust regex fallback anywhere, an errored tree yields zero Rust routes |
JS/TS (security-ast.ts)#
The old regex extractor over-captured badly: cache.get("user:1"), Hono's c.get("session"), config.get("PORT"), and axios.get(url) all look like routes to a naive pattern. The AST extractor closes this with two structural gates. The receiver must match ROUTER_RECEIVER = /^(?:app|application|server|router|api|route|v\d+|[a-z]*[Rr]outer)$/, and the first argument must be a string literal starting with /. Every one of the over-capture cases fails at least one gate.
ROUTE_METHODS is get/post/put/patch/delete/all, and MUTATING is post/put/patch/delete/all. Express's .all() matches every verb, so an unauthed .all() route is, as the source comment puts it, exactly as unsafe as an unauthed POST. This set is canonical: the batch detector derives its upper-cased MUTATION_METHODS from SECURITY_AST.MUTATING, and the in-loop classifier reuses the same set, so the two can never disagree about what counts as mutating.
Per-route middleware is the set of arguments strictly between the path and the final handler argument, matched by name against AUTH_MW (requireAuth, isAuthenticated, passport, authenticate, verifyToken, jwt, authMiddleware, ensureAuth, withAuth, checkAuth, authorize, requireLogin), plus analogous validation and rate-limit regexes. File-level middleware comes from .use(...) calls on router-like receivers only. The regex fallback, when no tree exists, examines a context window of 5 lines before to 20 lines after each registration.
Python (security-ast-python.ts)#
The AST path runs only on a clean parse (!tree.rootNode.hasError); any parse error sends the whole file to the legacy regex with its documented over-blesses, rather than mixing a partial tree with regex guesses.
Route decorators route, get, post, put, patch, delete, and api_route are recognized on a receiver that is either structurally resolved (an identifier assigned from Blueprint, APIRouter, Flask, or FastAPI anywhere in the file) or matches a naming-convention fallback (app, bp, blueprint, *_bp, *_blueprint, *_router, *_api, and similar). The same leading-slash path gate as JS applies.
Method resolution (resolveMethod) is deliberately conservative in the unsafe direction: verb decorators are direct; a bare @app.route defaults to GET, which is Flask's actual semantics; a literal methods=[...] picks the mutating verb; and a statically unresolvable methods= (a variable, computed expression, or splat) resolves to "ALL", keeping the route inside the mutating vote rather than silently dropping to GET. A methods=VAR reference is read through only when VAR is written exactly once, at module top level, to a literal list, tuple, or set; an elaborate poison census (collectPoisonedMethodsNames) disqualifies names touched by +=, subscript writes, global, walrus assignments, loop targets, with ... as, or .append().
DRF @api_view(["POST"]) function views are recognized with a synthesized path of /<function-name>; class-based views and urls.py emit zero routes.
A Python route counts as authenticated when any of the following holds: an AUTH_DECORATORS decorator sits below the route decorator (see the decorator section below); DRF @permission_classes([IsAuthenticated]) or [IsAdminUser] sits below @api_view; a FastAPI auth dependency appears in the handler parameters, the route call's dependencies= keyword, or the router constructor's dependencies=; or a receiver-scoped or app-scoped hook was verified as rejecting (next section).
FastAPI Depends(...)/Security(...) recognition is segment-based on the dependency's resolved name, never on raw expression text. The name is split into whole segments on underscores and CamelCase boundaries. A hit is a single segment from DEPENDS_AUTH_SEGMENTS (auth, authenticate, authenticated, jwt, oauth, oauth2, bearer) or an adjacent pair from DEPENDS_AUTH_PAIRS ("current user", "api key", "verify token", and similar). Any segment from DEPENDS_VETO_SEGMENTS (optional, maybe, anonymous, none, settings, config, stats, and others) cancels the hit outright: get_current_user_optional never blesses even if its body raises 401, because it admits anonymous requests by design. Whole-segment matching makes substring blessing structurally impossible: get_author_stats splits into [get, author, stats], and "author" is not "auth". As an additive path, a boring-named dependency whose body (same-file or cross-file-resolved) verifiably rejects does bless, through the same body classifier everything else uses.
Go (security-ast-go.ts)#
Same clean-parse gate as Python. Recognition covers Gin/Echo/chi verb fields in both casings (GET and Get), Any resolving to "ALL", verb-first forms (Method, MethodFunc, Add), and path-first Handle/HandleFunc with Gorilla's chained .Methods("POST") and Go 1.22 verb-in-pattern strings (http.HandleFunc("POST /orders", h)). Receivers resolve through naming conventions plus structural constructors (gin.Default, gin.New, echo.New, chi.NewRouter), group derivations (api := r.Group("/api")), Gorilla PathPrefix(...).Subrouter(), and chi Route("/admin", func(sub chi.Router){...}) closures.
Three recall gaps are pinned as deliberate: Gorilla route-builder chains (r.Methods("POST").Path("/x").HandlerFunc(h)) are not unwound, embedded-engine method receivers (s.POST where the struct embeds *gin.Engine) are missed, and a route registered under a runtime flag (if featureFlag { r.POST(...) }) is extracted unconditionally. All three err toward missing or over-flagging a route, never toward blessing one. The method convention is locked the same way: an unresolvable or absent method resolves to "ALL" (stays in the mutating vote); a fully literal but unrecognized verb such as .Methods("MKCOL") resolves to GET; HEAD and OPTIONS are always excluded.
Use and group scoping is position-aware. Marks are keyed by (receiver name, enclosing function scope, row), and a route inherits a mark only when the names and scopes match and the mark appears strictly before the route registration in source order (m.row < routeRow in the inheritance check). That is correct for Gin and chi, where a late Use genuinely does not apply, and it over-flags late-Use Echo and Gorilla code, which is the acceptable direction: it can never falsely bless. A Use call nested under an if, for, switch, or select never marks (conditionally registered auth is statically ambiguous, so it resolves to unprotected). A (name, scope) pair bound two or more times is poisoned. Recursion through group derivations is depth-capped.
Rust (security-ast-rust.ts)#
Rust is AST-only. Axum's builder is recognized structurally: a .route("/p", post(h)) call whose field is exactly route, with two arguments, a leading-slash string, and a second argument resolving to a verb callee (get/post/put/patch/delete; any and on(MethodFilter, h) resolve to ALL because the filter is not parsed). Actix and Rocket attribute macros (#[post("/users")], #[actix_web::post("/x")], and the generic #[route("/x", method = "POST")]) are read from the token tree; the attribute must be a preceding sibling of the function_item.
Layer scoping is the crux of Axum. .layer and .route_layer wrap only the routes registered before them in the chain, and in the syntax tree the layer call is the outer node while wrapped routes are its descendants. coveringLayerArgs therefore walks up from each route collecting only ancestor layer arguments, so .layer(auth).route(A), where A is registered after the layer, never blesses A. And because layer auth is chain-scoped, there is no file-level Rust auth OR at all: extractRustFileMiddlewareAst returns all-false unconditionally.
The never-false-bless invariant#
Every language module states the same design law: the detector may under-report auth (a recall miss becomes an over-flag that a human can dismiss) but it must never mark an unauthenticated route as authenticated. A false bless is invisible: the route simply never appears in any finding, and the one place the tool was supposed to help is exactly where it goes quiet. Every ambiguity in the pipeline resolves in the flag direction, and every recognition rule below exists to enforce that.
"Bless" throughout this chapter means: mark a route as having auth, removing it from the deviator side of the vote.
The five-rule precedence#
When a route's protection depends on a middleware, hook, or layer function, the guard's fate is decided by a body-first classifier. The precedence is identical, and identically commented as LOCKED, in Python (classifyHookAuth), Go (classifyGoMiddlewareAuth), and Rust (classifyRustAuth):
- A veto segment on the name resolves not-auth, even over a real body reject. A guard named
SkipAuthoroptionalAuthexists to disable or soften auth; blessing it because its body contains a 401 branch would invert its meaning. - A readable body with a verified reject resolves auth. This is the only bless path, and it works even under a completely boring name. What the code does outranks what it is called.
- A fully visible body that never rejects resolves not-auth. A name never rescues a visible body:
verify_user_emailwhose body just callsconfirm(user)is not authentication no matter how enforcement-flavored the name reads. - An opaque body resolves unsure at best. In Go and Rust, unsure only when the name is auth-flavored, otherwise not-auth. In Python, the "opaque" body signature itself already requires an auth-flavored unresolvable call or a credential read inside the body, so it hedges to unsure directly. Never auth.
- No readable body at all (imported, unreadable) behaves like rule 4. A name alone never blesses.
What counts as a verified reject#
Rule 2 hinges on "verified reject", and each language defines it against its own idioms (scanBody in Python, scanGoBody in Go, scanRustBody in Rust).
401 blesses alone; 403 only when credential-guarded. This asymmetry is documented at the constants in security-ast-python.ts and enforced in every scanner: 401 Unauthorized is specifically an authentication denial, but 403 Forbidden is routinely raised for CSRF failures, IP allowlists, maintenance gates, and generic policy. A bare abort(403) therefore contributes nothing, not even a hedge, so csrf_protect and maintenance_gate hooks neither bless nor pollute the hedged copy. A 403 blesses only when the guard condition structurally reads a credential surface (guardConditionHasCredentialRead): if "user_id" not in session: abort(403) blesses, while "login" in request.path and request.user_agent is None never drive one. Go additionally vetoes credential keys that are not credentials: a 403 guarded by c.GetHeader("X-CSRF-Token") stays classified as none.
Python reject catalogue: abort(401); raise HTTPException(status_code=401) including the HTTP_401_* constant forms; auth-flavored exception names (Unauthorized alone, or a topic plus kind pair like AuthenticationError); login-flavored redirects (return redirect(url_for("login"))); (expr, 401) return tuples; and verify_jwt_in_request() with an empty argument list. The non-empty form verify_jwt_in_request(optional=True) admits anonymous requests, so it is only an opaque hint, never a reject.
Go reject catalogue and corroboration: c.AbortWithStatus(401) blesses alone, and bare-integer and named-constant forms are treated identically (http.StatusUnauthorized resolves through a pinned constants map, but a repo-local status constant is never guessed). Plain write-status calls (c.JSON(401, ...), http.Error(w, ..., 401), w.WriteHeader(401)) bless only when corroborated by a following return or Abort* sibling in the same block, because a 401 write that keeps calling the next handler does not actually stop the request:
// NOT a reject: writes 401 but the request still reaches next
func f(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
next.ServeHTTP(w, r)
}Returned echo.NewHTTPError(401) and &echo.HTTPError{Code: 401} values count as rejects.
Rust produce-position gating: the strictest of the three. A 401 counts only when it is produced as a value: a return operand, a ? try operand (error propagation is a reject-return), or a block or body tail (Rust's implicit return), recursing through match arms, if/else branch tails, Err(...) wrappers, (STATUS, ...).into_response(), .ok_or(STATUS), and the closure tail of .ok_or_else(|| ...). A 401 appearing in a comparison operand, a call argument, a struct field, a discarded let, or a condition is a mention and never blesses. A semicolon-terminated tail discards its value, so it is not a produce position. Only named constants are read (StatusCode::UNAUTHORIZED, Status::Unauthorized, HttpResponse::Unauthorized, Actix's ErrorUnauthorized); a bare integer 401 is deliberately ignored in Rust.
Everywhere, nested closures and defs are pruned before scanning: a reject inside a goroutine, callback, or nested function is not executed inline, so it proves nothing about the guard. The Python and Go scanners follow at most one hop into a same-file helper, with a cycle guard; the Rust scanner reads only the resolved from_fn body itself, with no helper hop (an in-file callee is neither followed nor treated as opaque).
The three-way outcome and the hedge#
The classifier's outcome type is auth | not-auth | unsure in all three languages. The critical property of unsure is that it is not-authed internally: it never sets an auth lane, never enters any file-level OR, and never blesses. The Go module calls this the honesty invariant: "UNSURE hooks are NEVER OR-ed in."
What unsure does do is set RouteInfo.authUnsureHook to the hook's name, and only when hasAuth === false; a blessed route never carries the field, and every emit site enforces that. JS/TS routes and the regex fallback never set it at all, so their output serializes byte-identically to the pre-hedge format.
The renderer uses the field to hedge the finding copy (security-consistency.ts). A confident deviator renders flat; a hedged deviator renders as:
POST /x: auth not confirmed, double check hook 'verify_session'and the finding's recommendation gains a suffix of the form "N of these could not be confirmed: <noun> (<names>) may authenticate them but its body could not be verified. Double check those hooks before treating the routes as unauthenticated." The noun is language-aware through HOOK_PHRASE:
| Language of hedged hooks | Noun in the copy |
|---|---|
| Python | a before_request hook |
| Go | a middleware |
| Rust | an extractor or layer |
| Mixed languages in one finding | an auth hook |
Hedging applies only to the auth subcategory: authUnsureHook is an auth-only marker, and a route that also lacks validation or rate limiting keeps flat copy for those.
The hedge is copy only. A hedged route still counts as unprotected in every vote. The design refuses to trade the invariant for politeness: the honest statement is "we could not verify this", said while still counting the route as unverified.
Why route decorators bless by name but hooks never do#
There is one deliberate carve-out from the no-name-bless rule. AUTH_DECORATORS in security-ast-python.ts is a curated allowlist of route decorator names that bless on name alone: login_required, fresh_login_required, jwt_required, token_required, auth_required, requires_auth, require_auth, permission_required, roles_required, roles_accepted, admin_required, staff_required, superuser_required, verify_token, authenticated.
The rationale, from the source: a route decorator is applied per-route, making it a stronger, higher-confidence signal than a broadly applied hook. A developer who writes @login_required directly above one specific handler is expressing intent about that handler; a hook named require_login registered once for a whole app might be anything, which is why hooks go through the body classifier and can at best hedge.
The allowlist is matched by exact final name segment, never substring, so @author_stats cannot match. Bare requires is excluded (it collides with feature-flag and DI markers like @pytest.mark.requires), except in the bare-identifier call form @requires("admin"). flask-jwt-extended's @jwt_required(optional=True), or any optional= value other than the literal False, does not bless, mirroring the verify_jwt_in_request rule.
Position matters too. Python decorators apply bottom-up, so the auth decorator blesses only when it sits below the route decorator. @login_required stacked above @app.route leaves Flask's url_map holding the unwrapped handler, which is genuinely unauthenticated at runtime, and the detector gets that right by checking decorator rows. The same positional rule applies to DRF @permission_classes below @api_view.
The Rust extractor hedge#
Axum handlers commonly take auth as an extractor parameter (async fn h(user: AuthUser)). An auth-flavored extractor type (AuthUser, Claims, RequireAuth, Identity, Bearer, AuthenticatedUser, CurrentUser, LoggedInUser, Principal, JwtClaims) resolves unsure with the type name as the hedged hook. Version 1 does not read the type's FromRequest implementation even when it is in the same file; this is pinned as a LOCKED decision and documented as the module's biggest recall cost, accepted because impl resolution has enough ambiguity that a wrong read risks the invariant. An Option<AuthUser> or any Maybe/Optional wrapper vetoes the parameter entirely (an optional extractor admits anonymous requests, so it does not even hedge), and plain data extractors (Json, Query, Path, State, Form, and the rest) contribute nothing.
Cross-file guard resolution#
An imported hook (from .auth import verify_session) has no in-file body, which under rule 5 means unsure at best. src/drift/security-xfile-index.ts exists to do better without creating a new bless path: it resolves the import to the in-repo defining file and hands the classifier that body, which still has to verifiably reject through the same rule 2 everything else uses.
The index's governing rule is refuse-on-ambiguity: every ambiguous resolution returns null, never a guess. Resolution is path-anchored (a relative import resolved lexically to one candidate file), never name-searched across the repo, so a sibling file that happens to define a same-named symbol is never consulted. Refusals include: two candidate files (mod.py and mod/__init__.py both existing), a symbol defined twice in the target, absolute imports, relative imports that escape the root, a wildcard import anywhere in the importer, a name bound by more than one import, a same-file def/class/assignment shadowing the import, a parse error in the target, and re-export chains deeper than one hop. The build is deterministic: files sorted by relative path, trees reused rather than re-parsed.
Two details protect the invariant end to end. resolvePyHookBody returns the target file's own def table along with the body, so the body's one-hop helper calls resolve in the file where they actually live, and it returns the resolved original name, so the optional-veto check re-runs on the real name: an aliased optional hook (check imported as an alias of get_current_user_optional) can never bless through the alias.
The Go half maps a pkg.Symbol selector through the importer's alias-aware import table to a package directory, gated on a single-root go.mod module path: a replace directive or a nested go.mod disables Go cross-file resolution wholesale (buildDriftContext in src/drift/index.ts sets goModulePath undefined in those cases), because the path math stops being trustworthy. Additional Go refusals include value-shadowed qualifiers (a local variable named like the package, the killer value-shadow vector), dot-imports, package-name disagreements, and duplicate definitions. Only pure-selector targets with arity 0 or 1 resolve; a two-argument middleware constructor is Go dependency injection and is never classified.
The auth dominance vote#
With routes extracted and classified, phase 3 runs in securityConsistency.detect().
Suppression first. A route carrying an inline @vibedrift-public annotation (on its own registration line, or as a standalone comment on the line immediately above, with a guard so a trailing annotation on route N never suppresses route N+1) or whose file matches a security.allowlist glob in .vibedrift/config.json is removed from both the numerator and the denominator before any vote. Annotation matching is comment-aware per language: a # comment never suppresses a JS route, and a quoted annotation inside a string never counts. Every suppression emits a counted INFO audit finding through the hygiene analyzer id security-suppression, which renders but can never move the composite: a suppressed route always leaves a trail.
Grouping. The detector needs at least 2 routes overall, excludes health and infra paths (/health, /healthz, /ready, /metrics, /ping), and votes per top-level route directory (the route file's directory), so an intentionally public router is not judged against an admin router's baseline.
Denominators. The auth vote runs over mutating routes only: MUTATION_METHODS = [POST, PUT, PATCH, DELETE, ALL]. GETs were removed from the denominator because counting intentionally public reads made the "X of Y" line in the finding false. The "ALL" sentinel keeps Express .all(), Flask's unresolvable methods=, Gin's Any, and Axum's any/on inside the vote. The validation vote uses BODY_METHODS = [POST, PUT, PATCH, ALL] (DELETE usually has no body to validate), and the rate-limit vote uses all routes in the group.
Primary vote. analyzeSecurityProperty needs at least 2 applicable routes and fires when the protected ratio exceeds 0.75 and at least one route lacks the property. Severity is error above 2 deviators, else warning; confidence 0.75. The finding copy reads:
Auth middleware missing on 2 of 9 routes (after router-scope middleware inheritance)Uniform-auth-gap fallback. The ratio gate has a blind spot: at 0 percent auth the ratio is 0, the gate never fires, and an AI that wrote every mutating endpoint without auth would get a clean grade. When the primary vote returns null for a group, analyzeUniformAuthGap fires whenever at least one mutating route lacks auth (the uniformly unauthed group is the motivating blind spot, but a group the ratio gate left unjudged, say at 50 percent auth, lands here too), but only with a baseline reason: either the repo uses auth machinery anywhere (a repo-global regex over specific symbols like requireAuth, jwt_required, login_required, passport; deliberately global rather than group-scoped, because "the codebase knows how to auth" is evidence wherever it lives), or an intent hint from CLAUDE.md/AGENTS.md declares auth required. Confidence is 0.9 for a declared rule and 0.6 for the softer machinery signal; severity error above 2 unauthed routes. With neither signal it stays silent: it could be an intentionally public API, and asserting otherwise would be a guess.
The peer floor. MIN_SECURITY_PEERS = 4 in src/scoring/engine.ts: a route-consistency security finding whose vote saw fewer than 4 relevant routes is re-tagged from drift-security_posture to the hygiene id security_posture-advisory. It still renders, but it never dents the score; two routes disagreeing is too thin a sample to call a convention. The DriftFinding-side twin isBelowSecurityPeerFloor feeds scoredDriftView in src/drift/index.ts, which excludes below-floor findings from every drift-representation consumer at one source point. At or above the floor, damage ramps with the vote's sample size up to SAMPLE_FULL_CONFIDENCE = 8.
Sub-convention votes for the MCP. The persisted baseline (src/core/baseline.ts) keeps securitySubVotes, a separate per-sub-convention vote map keyed by the SECURITY_SUBCATEGORIES labels (Auth middleware, Input validation, Rate limiting). The general perCategoryVote map collapses all three into one security_posture slot by widest denominator, so without the sub-map, get_dominant_pattern('auth') could return whichever sub-convention happened to have the most routes. Each persisted vote carries a belowPeerFloor flag so MCP tools present thin votes as advisory, and the suppression audit is excluded from both maps.
Worked examples per language#
All fixtures below are taken from the shipped unit tests under test/unit/drift/.
Python#
Flagged (security-ast-python.test.ts): a plain FastAPI-style route extracts as unauthenticated.
@app.post("/orders")
def create_order():
return {}extracts POST /orders auth=false. Notably, a guard inside the handler body does not bless:
@app.post("/x")
def h():
if not g.user:
abort(401)
return {}still extracts auth=false. In-body enforcement is invisible to a decorator- and hook-level check, and resolving it to false is the correct never-false-bless direction: an over-flag, never an over-bless.
Blessed: a hook whose body verifiably rejects, blessing by rule 2 regardless of scope receiver.
@app.before_request
def require_login():
abort(401)sets the file's auth lane; the same body under @bp.before_request def check_auth() blesses too, while def check_auth(): pass does not, because a visible non-enforcing body is rule 3 not-auth even under a core auth name.
Hedged: the same hook name with an opaque body.
@app.before_request
def require_login():
check_session()
@app.route("/x", methods=["POST"])
def x():
return {}extracts POST /x with hasAuth=false and authUnsureHook="require_login", rendering as POST /x: auth not confirmed, double check hook 'require_login'. The same happens for an imported hook (from .auth import verify_session; app.before_request(verify_session) hedges on verify_session when the cross-file index cannot resolve it), an attribute target (app.before_request(AuthGate.check) hedges on AuthGate.check), and an unreadable abort code (abort(code_var)). Attribution is receiver-first: with an app-scoped unsure hook verify_global and a blueprint-scoped unsure hook verify_local, a route on orders_bp hedges verify_local.
Go#
Flagged (security-ast-go.test.ts): a route outside any guarded scope.
func routes() {
api := r.Group("/api")
api.Use(middleware.VerifyToken)
api.POST("/items", h)
r.POST("/public", h)
}POST /public extracts with hasAuth=false and no hedge: the group's mark belongs to api, not r.
Blessed: an in-file factory whose returned closure verifiably rejects, applied with Use. This fixture also shows auth beating unsure when both are present in scope:
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader("Authorization") == "" {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Next()
}
}
func routes() {
r.Use(RequireAuth())
r.Use(middleware.VerifyToken)
r.POST("/x", h)
}POST /x extracts hasAuth=true with no authUnsureHook: the confirmed mark wins and the hedge field stays absent. A credential-guarded 403 blesses the same way (tok := c.GetHeader("Authorization"); if tok == "" { c.AbortWithStatus(http.StatusForbidden); return } classifies as reject), while the bare c.AbortWithStatus(http.StatusForbidden), the CSRF idiom guarded by X-CSRF-Token, and a 401 inside go func(){ ... }() all classify as none.
Hedged: in the group snippet above, POST /items extracts hasAuth=false with authUnsureHook="middleware.VerifyToken": an imported selector has no readable body, the name is auth-flavored, rule 5 says unsure. With two unsure Use hooks in one scope, attribution is deterministic: the first in document order.
Rust#
Flagged (security-ast-rust.test.ts): a bare Axum route.
async fn h(body: Json<Order>) -> Response { todo!() }
fn app() -> Router {
Router::new().route("/x", post(h))
}extracts POST /x auth=false with no hedge (Json is a data extractor and contributes nothing). A .layer in a separate statement, or a .layer(auth) that precedes .route(A) in the chain, never blesses the route either.
Blessed: a covering from_fn layer whose body produces a reject.
async fn auth(req: Request, next: Next) -> Result<Response, StatusCode> {
let t = req.headers().get("Authorization");
if t.is_none() { return Err(StatusCode::UNAUTHORIZED); }
Ok(next.run(req).await)
}
fn app() -> Router {
Router::new().route("/x", post(h)).layer(middleware::from_fn(auth))
}extracts POST /x auth=true. The produce-position forms .ok_or(StatusCode::UNAUTHORIZED)?, .ok_or_else(|| StatusCode::UNAUTHORIZED), and a bare block-tail StatusCode::UNAUTHORIZED all bless the same way.
Hedged: an auth-flavored extractor parameter.
async fn h(user: AuthUser) -> Response { todo!() }
fn app() -> Router {
Router::new().route("/x", post(h))
}extracts POST /x auth=false with authUnsureHook="AuthUser" (likewise for Claims, RequireAuth, Identity, Bearer). Wrap the extractor in Option<AuthUser> and the optionality veto removes even the hedge.
JS/TS#
Flagged: app.post('/orders', handler) in a route group where more than 75 percent of mutating routes carry auth becomes a deviator: no middleware argument between the path and the handler matches AUTH_MW, and no file-level .use(...) sets the auth lane.
Blessed: either a per-route guard or a file-level one.
router.post('/refunds', requireAuth, createRefund); // middleware-position name matches AUTH_MW
router.use(requireAuth); // blesses every route in the fileNo hedge state: JS/TS routes never set authUnsureHook. The JS path blesses on middleware names (a documented, bounded exception to body verification, scoped to the position between path and handler where only middleware can appear), so there is no "auth-flavored but unverified" middle state to hedge; the hedged copy machinery only ever activates for Python, Go, and Rust routes. Meanwhile the over-capture gates keep non-routes out entirely: cache.get("user:1") and axios.get(url) fail the receiver and leading-slash checks and never enter any vote.
The in-loop twin#
classifyRouteAuth (src/drift/route-auth-classify.ts) reuses extractJsRoutesAst on a single proposed function body, so the MCP validate_change verdict can never contradict the batch detector for the same code. Router-scope middleware is deliberately not consulted: a single body cannot see its router's setup, and that invisibility is exactly why the consumer hedges to low confidence and never asserts "this route is unauthed" as fact. It is JS/TS only; other languages return null. The same never-false-bless posture, applied one editing loop earlier.
docs/handbook/06-security-consistency.md, then rebuild with node scripts/build-handbook.mjsLayer 1.7: Code DNA#
Layer 1.7 is the function-level analysis layer. Where the Layer 1 analyzers and drift detectors reason about files (which convention does this file follow, how does it compare to its peers), Code DNA reasons about individual functions: it extracts every function in the repo, reduces each one to compact structural signatures, and compares those signatures to find duplicated logic, workflow-shape similarity, architectural pattern mixing, and unsanitized input flows. Everything in this layer runs locally and costs nothing.
The orchestrator is runCodeDnaAnalysis(ctx) in src/codedna/index.ts. It runs one shared extraction pass and then five modules in order:
- Semantic fingerprinting (
semantic-fingerprint.ts): exact duplicates by normalized-body hash. - Operation sequences (
operation-sequence.ts): workflow-shape similarity over abstract opcodes. - Pattern classification (
pattern-classifier.ts): Bayesian architectural-pattern posterior per file. - Taint analysis (
taint-analysis.ts): two-phase source-to-sink flow tracking. - Deviation heuristics (
deviation-heuristics.ts): was this pattern deviation intentional or accidental?
Findings are aggregated from modules 1, 3, 4, and 5. Module 2's similarities are computed and kept on the result, but deliberately never become findings; the reason is explained in its section below.
Shared function extraction#
src/codedna/function-extractor.ts extracts functions with per-language regexes, not tree-sitter. It covers Go (func), JS/TS (function declarations and const x = (...) => arrows), Python (def, with an indentation-based body extractor), and Rust (fn). The arrow-function pattern includes a return-type skipper (skipReturnType) that balances {}, <>, and () so a TypeScript annotation like (): {a: number} => does not break body extraction.
Each ExtractedFunction (src/codedna/types.ts) carries the name, file, relative path, line, language, params, raw body, declaration code, a domainCategory, body tokens, a token count, and a body hash. Functions with bodies shorter than 10 characters or fewer than 5 tokens are dropped. domainCategory is a keyword classifier over the name (and sometimes body) into 17 categories such as formatting, validation, parsing, data_retrieval, authentication, with general as the fallback; the operation-sequence module later uses it to avoid comparing unrelated functions.
Module 1: semantic fingerprinting#
A semantic fingerprint is { functionRef, normalizedHash }, where the hash is computed over a normalized copy of the function body. Two functions with the same normalized body are exact semantic duplicates: same logic, possibly different variable names, comments, and whitespace.
Normalization preserves literal values#
normalizeBody in src/codedna/semantic-fingerprint.ts performs, in order:
- Strip line and block comments (
//,#,/* */). - Stash string and template literals under opaque
LIT{n}placeholders so the next step cannot touch their contents. - Collect locally declared variable names (
var/let/const, Go:=, Python top-level assignments) and rewrite each to a positional placeholder_v0,_v1, and so on (whole-word, longest name first). - Restore the stashed literals verbatim.
- Collapse all whitespace.
Step 4 is the load-bearing design decision: literal values survive into the hash. An earlier version collapsed strings to a generic STR token, and that produced a real false positive: isHighCorrectionMode and isLowCorrectionMode have the same shape (return mode === '...' || mode === '...';) but compare against different constants. The constants carry the semantics. Only identifier names are interchangeable; values never are. The regression and its fix are documented in the file header and pinned by tests in test/unit/codedna/semantic-fingerprint.test.ts.
The hash itself is a double-pass FNV-1a (a fast, non-cryptographic hash function): one forward pass seeded 0x811c9dc5 and one backward pass seeded 0x1a47e90b, concatenated into 16 hex characters.
Grouping and verification#
findDuplicateGroups buckets fingerprints by the FNV-1a hash, then re-hashes each bucket member's normalized body with SHA-256 to eliminate FNV collisions (a regression test feeds 1000 structurally unique bodies through and asserts zero groups). A group is emitted only when at least 2 functions share the SHA-256 and span at least 2 distinct files: two identical bodies inside one file are common and are not drift. Groups whose members all live in non-shippable paths are dropped (see the filter section below).
The finding and its calibrated confidence#
fingerprintFindings emits analyzer id codedna-fingerprint. Severity is graded by blast radius, the duplicate-group size n: info at 2 members, warning at 3 or 4, error at 5 or more. Message names are capped at 10 and locations at 20 so a registry-style repo with a 200-member group cannot balloon one finding to tens of kilobytes (a regression test holds a 200-member finding under 4KB). The group size rides along as dupGroupSize for the scoring engine.
Confidence is the constant STRUCTURAL_DUP_CONFIDENCE = 0.95, and the code comment documents where that number comes from: it was calibrated, not guessed. On 2026-06-24, 79 real codedna-fingerprint duplicate groups harvested across 7 repos were each judged by Claude (claude-sonnet-4-6, the production deep-scan judge prompt); measured precision was 98.7% (78 of 79), with a 95% Wilson confidence interval of [93.2, 99.8]. The constant is set to 0.95, slightly below the measured value, shading toward the interval floor because the sample was JS/TS-only.
Example: what groups and what does not#
Adapted from the real fixtures in test/unit/codedna/semantic-fingerprint.test.ts. This pair fingerprints as a duplicate group because the bodies are identical and the shared literals (0 and 1) match:
// src/a.ts and src/b.ts, byte-identical body
function clamp01(value: number): number {
if (value <= 0) return 0;
if (value >= 1) return 1;
return value;
}VibeDrift reports one codedna-fingerprint finding: "Exact semantic duplicate: clamp01(), clamp01() have identical normalized bodies across 2 files", severity info (2 members), confidence 0.95.
This pair does not group, even though the shape is identical, because the string literals differ and literals are preserved in the hash:
// src/background/audio/tempo-beat-correction.ts
return mode === 'high-overshoot' || mode === 'high-overread-nonclassic';
// src/background/audio/tempo-correction-support.ts
return mode === 'low-ambiguous' || mode === 'mid-underread';No finding is emitted. Renaming every variable in clamp01 would still group it; changing 0.05 to 0.08 in a tax helper would not.
The MinHash pipeline: the shared near-miss engine#
Exact hashing only catches identical bodies. Near-duplicates (a copied function with one edited line) need a similarity search, and comparing every function against every other is quadratic. src/codedna/minhash.ts implements the standard answer: MinHash plus locality-sensitive hashing (LSH), with an exact verification step at the end. The pipeline is tokenize, normalize, shingle, MinHash, LSH, then LCS verify.
The default constants, from src/codedna/minhash.ts:
export const DEFAULT_SHINGLE_SIZE = 5;
export const DEFAULT_PERMUTATIONS = 128;
export const DEFAULT_LSH_BANDS = 16;
export const DEFAULT_LSH_ROWS = 8;Stage by stage:
- Tokenize: strip comments; the token regex captures identifiers, numbers, and single-character punctuation. String literals become
"STR"and numbers becomeNUMduring normalization. Unlike the exact-duplicate tier, this engine wants near-misses, so literal values are noise here rather than signal. - Normalize, preserving call targets: local and parameter identifiers become positional
ID0, ID1, ..., but any dotted chain ending in a call, likedb.query(orRepository.find(, stays literal. The file header states the reason: the API a function calls is architectural signal, not noise. Two functions that do the same thing against different APIs should score lower than two that call the same API. - Shingle: overlapping windows of 5 consecutive tokens. Similarity between two token streams is then Jaccard similarity over their shingle sets (the size of the intersection over the size of the union).
- MinHash: 128 seeded FNV-1a permutations compress each shingle set into a 128-slot signature; the fraction of matching slots between two signatures estimates their Jaccard similarity. The seeds are derived arithmetically rather than being a true random permutation family; a comment documents the measured deviation as under 1% for well-mixed inputs.
- LSH: each 128-slot signature is cut into 16 bands of 8 rows. Two functions become a candidate pair if any band matches exactly. The collision probability for true Jaccard similarity
sisP = 1 - (1 - s^8)^16: 99.99% at s = 0.9 (the file header's table rounds this one to 99.999%), 94.7% at s = 0.8, 61.3% at s = 0.7, 6.1% at s = 0.5, and 0.1% at s = 0.3. The curve is the point: near-duplicates almost always collide, unrelated code almost never does, and the quadratic comparison collapses to just the colliding pairs. - LCS verify: every candidate pair is confirmed with an exact longest-common-subsequence similarity,
2 * LCS(a, b) / (|a| + |b|), with a fast rejection to 0 when the shorter stream is less than half the longer one. The code comment claims LCS similarity cannot exceed 0.5 in that case; the true bound is 2/3 (2 * min / (min + max)approaches it from below), which still sits safely under the 0.7 flag threshold, though 0.6-threshold consumers can in principle lose a pair in the 0.6 to 0.66 range to this shortcut. This is the number consumers threshold on.
buildSignature(source) is the one-shot helper returning { tokens, shingles, signature }; it is reused by the drift detector, the persisted baseline builder, and the MCP tools, so every consumer measures similarity in exactly the same space.
One engine, several thresholds#
Different consumers ask the engine different questions, so they gate at different points:
| Consumer | Threshold | Configuration | Why |
|---|---|---|---|
src/drift/semantic-duplication.ts (batch drift detector) | LCS >= 0.7, min 15 body tokens, cross-file only | 16 bands x 8 rows | Precision-tuned discovery: flags rename-refactor duplicates, rolls up per directory |
src/ml-client/sampler.ts (deep-scan sampler) | band [0.55, 0.8], inclusive at both ends | 32 bands x 4 rows | Recall-oriented: surfaces the ambiguous pairs the precision-tuned detector excludes, so the cloud judge can rule on them |
find_similar_function MCP tool | LCS >= 0.6, cap 20 | exact LCS against every index entry, no LSH gate | One-vs-N discovery search over the baseline index |
validate_change MCP tool | LCS >= 0.8, cap 20 | exact LCS against every index entry, no LSH gate | A change introducing a near-clone warrants a stricter bar than discovery |
The MCP one-vs-N path (src/codedna/find-similar-to-body.ts) skips LSH entirely: with a single query against a prebuilt index, the exact scan is cheap and covers the full similarity range including exact duplicates.
Module 2: operation sequences#
extractOperationSequences reduces each function body to a sequence of abstract opcodes, one of 22 values defined in src/codedna/types.ts:
INPUT, AUTH, VALIDATE, CACHE_READ, CACHE_WRITE, QUERY, MUTATE, METRICS, EMIT, API_CALL, RETRY, LOCK, RESOURCE, LOG, SERIALIZE, DESERIALIZE, AGGREGATE, LOOP, BRANCH, TRANSFORM, RETURN_OK, RETURN_ERR.
Each line is classified by a first-match-wins regex table whose ordering is deliberate: RETURN_ERR is checked before BRANCH, for example, so Go's if err != nil { return err } classifies as one error-return op instead of a branch. Consecutive duplicate ops are collapsed, so ten TRANSFORM lines in a row read as one.
findSequenceSimilarities compares pairs that are cross-file, share the same non-trivial domainCategory (skipping general and request_handling, which are too broad to mean anything), have at least 3 ops each, and are not both non-shippable. Similarity is LCS(seqA, seqB) / max(|A|, |B|), flagged at 0.80 or higher, and then a specificity gate is applied: the shared subsequence must carry at least 2 distinctive ops (anything other than TRANSFORM, BRANCH, LOOP) or be at least 6 ops long. Without that gate, two short functions that both loop and branch would trivially clear 0.80 while having nothing in common.
Operation-sequence similarities are deliberately not surfaced as findings. The aggregation comment in src/codedna/index.ts explains why: opcode LCS measures workflow-shape similarity, which is a consistency signal, not evidence of duplicate logic. Sibling command handlers legitimately share a load, validate, log, return shape, so reporting them as near-duplicates conflates correct consistency with redundancy; it was a systematic false-positive source (for example runDoctor vs runLogout). Genuine duplicates are caught by the body-level detectors instead. The data stays on CodeDnaResult.sequenceSimilarities, where the drift signal and the deep-scan preview use it.
A sequenceFindings emitter exists in operation-sequence.ts (analyzer id codedna-opseq) but is not wired into the aggregate findings.
Module 3: Bayesian pattern classification#
pattern-classifier.ts classifies handler- and service-like files (paths matching handler, controller, service, route, endpoint, api, resource) into an ArchPattern: repository, raw_sql, orm, direct_db, http_client, or none. Signals are regexes (SQL statement literals, cursor.execute(, ORM imports, injected-repository access, fetch(/axios., and so on), each tallied for its pattern.
Counting signals alone is a likelihood-only estimate, and the file header describes the problem with it: a file in handlers/ with 3 repository signals and 1 raw-SQL signal was 75% repository, full stop, no matter where it lived. The classifier instead combines the likelihood with a context prior via Bayes' rule:
P(pattern | signals, context) ∝ P(signals | pattern) · P(pattern | context)The prior comes from two sources. Directory semantics: a path under repositor(y|ies)/, store/, or dal/ multiplies the repository pattern's prior by 2.0 and http_client's by 0.5; migrations/ boosts raw_sql by 2.0, because raw SQL in a migration is expected, not drift. Language: Go nudges repository (1.2) and raw_sql (1.1), Python nudges orm (1.3), Rust nudges repository (1.1), JS/TS stays uniform. The effect is that the same signals land at high confidence where context reinforces them and lower confidence where it does not.
A file is isInternallyInconsistent when its maximum posterior is below 0.6 while more than one pattern has evidence. patternFindings emits codedna-pattern: a warning when a file's dominant pattern deviates from the project-wide dominant, and an info "Mixed patterns" finding (confidence 0.6) for internally inconsistent files.
Module 4: two-phase taint analysis#
taint-analysis.ts tracks user-controlled input from sources to dangerous sinks. Taint analysis means: mark values that came from outside (URL parameters, request bodies), propagate the mark through assignments, erase it at sanitizers, and report when a marked value reaches a sink that must never receive raw input.
Phase 1 is intraprocedural: within each function, variables assigned from a taint source are tracked through the body; a flow is emitted when one reaches a sink.
Phase 2 is one-hop interprocedural: each function gets a summary, paramsTainted, the set of parameter indices that would reach a sink inside it. Then every call site g(arg1, arg2, ...) is checked: if the caller's arg_i is tainted and i is in g's summary, a one-hop flow is emitted. The header states why it stops at one hop: the dominant real-world pattern is a handler passing unsanitized input to a service, and a full fixpoint iteration is expensive while rarely catching what one hop misses. Calls are resolved by bare function name only (no module scoping), which the header lists as a limitation.
Sources, sinks, and sanitizers are per-language regex tables:
| Table | Examples |
|---|---|
| Sources | Go c.Param(, mux.Vars(; JS/TS req.params.*, req.body.*, Hono c.req.param(; Python request.args.get(; Rust web::Path |
| Sinks (categorized) | sql_injection, command_injection, path_traversal, xss, code_injection, ssrf; each sink carries its own error or warning severity |
| Sanitizers | zod/joi schema.parse (clears all categories); parseInt / strconv.Atoi (clears sql_injection); $N / ? query parameterization; DOMPurify (xss); path.join / path.resolve (path_traversal) |
taintFindings emits codedna-taint findings at confidence 0.75, deduplicated by file, function, and sink type, with severity taken from the sink definition. The exported INJECTION_SINK_LABELS (the code- and command-injection sink labels) feed the render-only security-floor badge described in the Output Surfaces chapter.
Module 5: deviation heuristics#
When the pattern classifier finds a file deviating from the project's dominant architecture, the interesting question is not "does it deviate" but "was that on purpose". deviation-heuristics.ts scores each deviating file from 0 to 1 as 0.5 + sum of signal weights, clamped. The default weights live in DEFAULT_DEVIATION_WEIGHTS in src/core/config.ts:
| Signal | Weight | Reading |
|---|---|---|
complex_sql | +0.15 per indicator, capped at 2x | Complex SQL suggests a deliberate performance escape hatch |
explanatory_comment | +0.20 (absence costs 0.1) | Someone wrote down why |
special_directory | +0.20 | Migrations, scripts, and similar homes expect deviation |
simple_crud_penalty | -0.30 | Trivial CRUD in raw SQL has no justification story |
same_directory_penalty | -0.20 | Deviating right next to conforming peers looks accidental |
git_recency | +0.15 | Recently touched code is more likely a live decision |
adjacent_test | +0.15 | Tested deviations look intentional |
adr_mention | +0.25 | Referenced in an architecture decision record |
Weights are overridable per project via .vibedrift.json under deviation_heuristics.signal_weights. Verdicts: likely_justified at 0.6 and above, likely_accidental at 0.3 and below, uncertain between. Only likely_accidental becomes a finding (codedna-deviation, severity warning, confidence max(0.5, 1 - justificationScore)); justified and uncertain deviations are recorded on the result but stay silent, because flagging an intentional, documented deviation trains users to ignore the tool.
The non-shippable filter#
src/codedna/nonshippable.ts classifies paths as non-shippable: generated code (generated/, .pb.go, _pb2.py, .min.*), fixtures, mocks, snapshots, tests (__tests__/, .test.*, _test.go, test_*.py), and examples/demos/samples.
The consumption rule matters more than the list: allNonShippable drops a duplicate group only when every member is non-shippable. Two identical mkCtx() helpers in two test files are not a consolidatable duplicate, but a src/ helper copied into a test still surfaces, because that group has a shippable member. The header documents that this mirrors the paid API's pre-filter (api/models/dup_prefilter.py in the API repo), so the free CLI and the cloud path agree on what counts.
Code DNA primitives outlive the scan. The persisted per-repo baseline that powers the MCP server (src/core/baseline.ts) builds its minhashIndex from extractAllFunctions plus buildSignature, which is why the in-loop duplicate checks in the MCP chapter measure similarity in exactly the space described here.
docs/handbook/07-code-dna.md, then rebuild with node scripts/build-handbook.mjsScoring: From Findings to the Vibe Drift Score#
Every analyzer, drift detector, and Code DNA module ends in the same place: a flat list of Finding objects. The scoring engine (src/scoring/engine.ts) turns that list into the headline Vibe Drift Score, a parallel Hygiene Score, five category scores, per-file scores, and per-finding fix impacts. This chapter documents the real engine as shipped, SCORING_VERSION = "v11", including the design history that explains why it is not a weighted sum.
Two tracks, one engine#
computeScores runs the same category computation twice over the same findings, split by analyzer kind (src/scoring/categories.ts):
- Drift track: only drift-kind findings (dominance votes, similarity signals, taint flows, learned patterns). Its composite is the Vibe Drift Score, the number on the report.
- Hygiene track: hygiene-kind findings (classic linter territory: empty catches, dead code, generic security rules). Same math, separate score, separate pane.
The split is the product boundary in code. VibeDrift measures drift, deviation from the repo's own dominant patterns; a generic finding that any linter could produce must not move the headline number, however useful it is to show.
The detector-level damage model#
Findings within a category are first grouped by analyzerId. Each detector group then contributes exactly one bounded damage term, and the category's health is the product of the survivals, a structure known as a noisy-OR (each detector is an independent chance of "damaging" the category, and health is the probability of surviving all of them):
damage_d = min(0.85, severity_d × confidence_d × importance_d × deviation_d × sampleConf_d)
health_c = Π_d (1 − damage_d)
categoryScore_c = 20 × health_c
composite = 100 × exp( Σ_c ln(max(0.02, health_c)) / |applicable categories| )Grouping by detector, not by finding, is the first size-fairness decision: 30 findings from one detector damage a category exactly as much as that detector's worst deviation warrants, never 30 times as much. Raw finding count scales with codebase size; the number of distinct drifting patterns, and how badly each drifts, does not.
The five factors of damage_d (all in detectorDamage, src/scoring/engine.ts):
| Factor | Definition | Constants |
|---|---|---|
severity_d | worst severity in the group, mapped through SEVERITY_DAMAGE | error 0.7, warning 0.4, info 0.12 |
confidence_d | mean confidence across the group's findings | default 1.0 |
importance_d | max file-importance weight over the group's locations | see the weights table below |
deviation_d | how far the repo actually deviates on this axis (three shapes, below) | floor 0.05 |
sampleConf_d | dominance sample-size weight: min(1, maxTotalRelevantFiles / 8) | SAMPLE_FULL_CONFIDENCE = 8 |
and the cap MAX_DETECTOR_DAMAGE = 0.85 guarantees that no single detector can remove more than 85% of a category, so one catastrophic axis cannot erase the information carried by the others.
File-importance weights#
The score measures drift in the code a project ships. Drift in generated output, fixtures, tests, and examples is analyzed and reported, but weighted down in the composite (computeFileImportanceWeight):
| Path class | Weight | Examples |
|---|---|---|
| Generated / fixtures | 0.05 | generated/, *.gen.ts, *.pb.go, _pb2.py, fixtures/, __mocks__/, __snapshots__/ |
| Tests | 0.35 | tests/, __tests__/, *.test.*, *_test.go, test_*.py |
| Examples / demos | 0.35 | examples/, demos/, samples/ |
| Entry points | 1.5 | index.*, main.*, app.*, server.*, lib.rs, mod.rs, *.config.*, .env* |
| Everything else | 1.0 |
The rationale is calibrated, not aesthetic: the comment above these constants records that roughly 82% of trpc's real duplicate groups live in examples, tests, and generated code, not in packages/src. Down-weighting is the honest lever for that, rather than distrusting a duplicate detector that is precise about what it found.
The deviation term: three shapes#
groupDeviation picks the deviation formula by what evidence the group carries:
- Dominance detectors (every finding in the group has a
driftSignal): deviation is the worst1 − consistencyScore/100in the group, that is, the largest share of relevant files that deviate from the dominant pattern. It is a rate, already size-normalized. A nonzero deviation is floored atDEVIATION_FLOOR = 0.05so a flagged-but-nearly-consistent pattern still registers faintly, but an exact zero stays zero: a finding whose group turned out fully consistent is not drift and must contribute no damage. - Grouped duplicates (
dupGroupSize > 1present): deviation is1 − e^(−dupFraction/0.15), wheredupFractionis the sum of redundant copies (dupGroupSize − 1per group, each weighted by where the group's files live) divided by the total function count. Thirty-two identical functions register as roughly 31 redundant copies no matter how the detector chunked them into findings. Because file importance is baked in per duplicate group here,detectorDamageuses a neutral importance for duplicate detectors to avoid double-counting. - Other count-based detectors (Code DNA similarity, ML findings, hygiene analyzers): deviation is a saturating per-function rate,
1 − e^(−(count/functions)/0.5), falling back to a per-KLOC density1 − e^(−(count/KLOC)/2.0)when the function count is unknown. The per-function rate (added in v6) is what keeps large repos fair: structural-similarity findings scale with function count, so a per-KLOC density kept rising with repo size and unfairly sank large clean repos.
The sampleConf_d factor exists for the same statistical reason in the other direction: a 70% majority over 4 files is far weaker evidence of a convention than the same share over 40 files, so dominance findings earn full damage weight only once the vote saw at least 8 relevant files. Count-based findings carry no dominance sample and pass through at 1.0.
Category scores and the empty-category problem#
A category with findings gets 20 × health. A category with no findings is genuinely subtle, and computeCategoryScore treats two cases differently:
- Surface-specific drift categories (
securityPosture,intentClarity): zero findings could mean "clean" or could mean "this repo has no routes to vote on." The engine cannot tell the difference, so on the drift track an empty one is marked not measured and excluded from the composite entirely, rather than credited a free 20/20 that would dilute the categories that were measured. - All other empty categories earn evidence-weighted clean credit:
frac = 0.8 + 0.2 × (1 − e^(−LOC/2500))of the max score. "No drift found" in 50 lines of code is weak evidence; in 8000 lines it is strong. Without this, every category in a tiny repo returned the max and the composite floated to ~100 purely because the repo was small (measured before the fix: repos under 20 functions had a median score of 100, repos over 500 functions a median of 82). The prior 0.8 is the elite-corpus mean health, so a thin-evidence clean repo lands near the population mean rather than getting either a free 100 or a punitive markdown.
The composite: a geometric mean#
The Vibe Drift Score is the geometric mean of category healths over the applicable categories, computed in log space for numerical stability, with each health floored at HEALTH_FLOOR = 0.02 so one fully-collapsed category collapses the composite without hard-zeroing it into illegibility.
A geometric mean is multiplicative: a repo scoring 0.95/0.95/0.30 composites to about 65, where an additive average would report a reassuring 73. That asymmetry is deliberate. One collapsed dimension (say, security consistency in shreds) is not offset by tidiness elsewhere, and the old additive engine's floor near 75 was one of the main reasons it stopped discriminating.
One category never appears on the drift track at all: dependencyHealth has only hygiene-kind analyzers (dependencies, config-drift), so under the drift kind-gate it has zero applicable analyzers, is marked applicable: false, and simply drops out of the geometric mean's denominator. There is no /80 to /100 rescale; the mean is taken over whatever is applicable.
The five categories and what feeds each#
All five categories have maxScore: 20 (src/scoring/categories.ts):
| Category | Drift-kind feeders | Hygiene-kind feeders |
|---|---|---|
| Architectural Consistency | naming, imports, drift-architectural_consistency, drift-naming_conventions, drift-async_patterns, drift-import_style, drift-export_style, drift-return_shape_consistency, drift-logging_consistency, drift-state_management_consistency, drift-test_structure_consistency, codedna-pattern, codedna-deviation, ml-anomaly | error-handling, language-specific |
| Redundancy | drift-semantic_duplication, drift-phantom_scaffolding, codedna-fingerprint, codedna-opseq, ml-duplicate, ml-reimplementation-concentrated | duplicates, todo-density, dead-code, ml-reimplementation |
| Dependency Health | (none; never on the drift track) | dependencies, config-drift |
Security Consistency (securityPosture) | drift-security_posture, codedna-taint | security, security-floor, security_posture-advisory, security-suppression |
| Intent Clarity | drift-comment_style_consistency, ml-intent | intent-clarity, complexity, implementation-gap |
The report also renders 13 per-drift-category bars (import style, logging consistency, and so on). Those are not a second scoring formula: each bar is the same categoryHealth for that one detector, multiplied by a display weight from DRIFT_WEIGHTS (src/drift/types.ts), a faithful decomposition of the composite's terms. Collapsing the previous separate formula into this decomposition was the v3 "dual-engine collapse."
Upstream signal shaping: entropy gate and temporal decay#
Two mechanisms in src/drift/utils.ts shape the dominance signals before they ever reach the engine, and both directly set the numbers the damage model consumes.
The entropy gate decides whether a convention exists at all. Given the vote distribution for a pattern axis, it computes normalized Shannon entropy; above 0.8 there is no dominant convention, so the detector reports a single "no convention" observation at confidence 0.75 instead of flagging half the repo as deviant. Below the gate, deviators are flagged with confidence max(0.3, min(0.9, 1 − normalizedEntropy)): the tighter the convention, the more confident the deviation. That confidence is exactly the confidence_d the engine averages.
The temporal weight makes votes recency-aware when git metadata exists: each file's vote is scaled by 2 × e^(−ln2 × daysAgo / 90), so a file touched today counts 2x, at 90 days 1x, at 180 days 0.5x, and at a year roughly 0.12x. The point is migrations: three recent files should outvote ten stale ones when the codebase is actively moving away from an old pattern, otherwise every in-progress migration reads as drift. Files without metadata weight 1.0, which reproduces the pre-temporal behavior. The vote's outputs, consistencyScore and totalRelevantFiles, become the engine's deviation and sample-confidence inputs through the finding's driftSignal.
Gates that re-tag findings#
Two calibrated gates run at the top of computeScores, both implemented as pure re-tags of analyzerId between hygiene and drift kinds. Re-tagging (rather than deleting) is the lever this architecture offers for "show it, but don't score it."
Reimplementation concentration gate (applyReimplementationConcentrationGate). Panel-confirmed ml-reimplementation findings (a deep-scan output) are hygiene-kind by default and re-tag to the drift-kind ml-reimplementation-concentrated only when there are at least REIMPL_CONCENTRATION_MIN_COUNT = 3 of them and their density reaches REIMPL_CONCENTRATION_DENSITY_MIN = 1.0 per KLOC. The calibration story is in the source comment: across a 425-repo corpus, raw reimplementation count did not separate elite repos from AI-sprawl repos (large elite repos carry a sparse reimplementation baseline of legacy files and parallel platform implementations), but density did: 0 of 249 elite repos reach 1 finding/KLOC. The gate is deliberately conservative in the "clean" direction; sparse reimplementation stays informational.
Security min-peer floor (applySecurityMinPeerFloor). A route-consistency drift-security_posture finding whose dominance vote saw fewer than MIN_SECURITY_PEERS = 4 relevant routes re-tags to the hygiene id security_posture-advisory. Below that floor, a single deviating route is too large a fraction of too small a sample to trust as a repo-level drift claim; the finding still renders, as an advisory, but never dents the composite. The same floor is applied a second time in buildScanResult before rendering, and renderers that read the raw drift-finding view use the shared predicate isBelowSecurityPeerFloor, so no export surface can list a security drift finding that the category scored as N/A.
Worked example: two findings to a category score#
Take a 12,000-line repo with 400 extracted functions, and two drift-kind findings in Architectural Consistency:
- Finding A, from the import-style drift detector (
drift-import_style): warning, confidence 0.85, insrc/api/users.ts(weight 1.0), withdriftSignal = { consistencyScore: 80, totalRelevantFiles: 20 }(16 of 20 relevant files follow the dominant import style). - Finding B, from the static
naminganalyzer: warning, confidence 0.62, in a normal source file. NodriftSignal, so it takes the count-based path.
| Factor | Finding A (drift-import_style) | Finding B (naming) |
|---|---|---|
| severity | warning: 0.4 | warning: 0.4 |
| confidence | 0.85 | 0.62 |
| importance | 1.0 | 1.0 |
| deviation | 1 − 80/100 = 0.20 | 1 − e^(−(1/400)/0.5) ≈ 0.0050 |
| sample confidence | min(1, 20/8) = 1.0 | 1.0 (no dominance sample) |
| damage | 0.4 × 0.85 × 1.0 × 0.20 × 1.0 = 0.068 | 0.4 × 0.62 × 1.0 × 0.0050 × 1.0 ≈ 0.0012 |
The two findings come from different detectors, so the category multiplies their survivals:
health = (1 − 0.068) × (1 − 0.0012) ≈ 0.9308
score = 20 × 0.9308 = 18.6 / 20Note the asymmetry the model is built for: the dominance finding (a real 20% deviation across 20 files) costs 55 times more than the single count-based naming finding in a 400-function repo. Suppose the other drift categories land at Redundancy 17.6/20 (health 0.88) and Security Consistency 19.0/20 (health 0.95), with Intent Clarity unmeasured (no comment-style or intent findings) and Dependency Health not applicable on the drift track. The composite averages the three measured healths geometrically:
composite = 100 × exp( (ln 0.93 + ln 0.88 + ln 0.95) / 3 ) ≈ 92.0 → grade ALetter grades are assigned at render time: A at 90+, B at 75+, C at 50+, D at 25+, F below (src/cli/commands/scan.ts, kept in sync with the HTML renderer's gradeFor()).
Per-file scores and fix projections#
computePerFileScores applies the same detector-level noisy-OR scoped to each file's findings, with deviation treated as full (the file is the deviator on its own axes), bounded by the same 0.85 cap, on a 0 to 100 scale.
Two "what if" surfaces come from the same machinery. consistencyImpact, stamped on each drift finding, is the exact score gain from removing that finding alone, computed by recomputing the category without it (O(n²) per category, but findings per category are few); an emptied category routes through the same evidence-weighted clean-credit path, not a free maximum. estimateScoreAfterFixes does a real recompute on the remaining set and additionally returns consistencyGain, the summed per-category point gain, which is provably between the largest individual impact and the sum of all impacts, because the noisy-OR is sub-additive. The Fix Plan displays that value, so the projected total and the per-item impacts can never contradict each other.
There is also a peer-percentile hook: compositeToPercentile places the composite on the empirical CDF of a bundled per-language corpus distribution (src/data/score_percentiles.json). The shipped artifact is currently a placeholder with an empty languages map, so every lookup returns null and the renderer shows nothing; the mechanism is live, the data is pending.
SCORING_VERSION: shipping score changes without gaslighting users#
Scoring methodology changes make raw scores from different versions incomparable, and a user who sees their score drop 6 points wants to know whether their code got worse or the ruler changed. The engine's answer is SCORING_VERSION (currently "v11"), with three coordinated behaviors:
- Cross-version delta suppression. History stores the
scoringVersionalongside each scan's scores. WhencomputeScoresreceives previous scores from a different version, it refuses to compute per-category deltas (they would be in different units) and returnspreviousScoresMismatch: "scoring-version-mismatch", which downstream silently hides delta arrows. - A one-time notice, never a banner.
shouldShowScoringNotice(src/core/scoring-notice.ts) shows a single line ("We refined how the Vibe Drift Score is calculated this release... What changed → https://vibedrift.ai/releases") exactly once per version change, then recordslastSeenScoringVersion. Brand-new users with no history see nothing. Users never see the version string itself. - A written history. The version block in
engine.tsrecords every methodology change:
| Version | Change |
|---|---|
| v1 | raw composite on a /80 scale, no normalization |
| v2 | composite normalized to /100 at the engine boundary (0.7.0) |
| v3 | all 14 drift detectors wired into the composite (was 3); single engine, dual-engine collapse |
| v4 | decompressed scoring: dominance-ratio magnitude, no per-analyzer cap, no sqrt-LOC dampener, multiplicative geometric-mean composite, real 0 to 100 range |
| v5 | evidence-weighted clean credit for no-finding categories; deep-scan dedup-aliasing fix |
| v6 | size-fair count normalization by per-function rate instead of per-KLOC density (trpc moved 69.6 to 81.6, TanStack 62.1 to 76.4) |
| v7 | concentrated reimplementation feeds the composite via the density gate |
| v10 | Express .all() and Flask methods=[...] mutating routes enter the security auth/validation votes |
| v11 | multi-language security auth (body-first route and auth extraction for Python/Go/Rust, cross-file hook resolution, conservative "unsure"); AST import graph with real module resolution |
(The comment records no v8 or v9 entries.)
Why not a simple weighted sum#
The first engine was one. Through v3 the score summed severity-derived weights over findings and mapped the sum through an exponential decay, score = maxScore × e^(−K × weight) with K = ln(2)/15, plus patches that accumulated around it: per-analyzer caps, a sqrt-LOC dampener, a correlation amplifier. That formula lingered in older docs long after v4 (the since-retired ARCHITECTURE.md still described it); treat the engine source, src/scoring/engine.ts, as authoritative. The v4 rewrite ("decompressed scoring") replaced the whole structure, and the reasons are the design rationale for everything above:
- Finding counts scale with repo size; drift does not. A weight sum grows with LOC, so big repos scored worse for being big. The sqrt-LOC dampener treated the symptom. The noisy-OR treats the cause: detectors contribute deviation rates and per-function rates, which are size-invariant.
- Additive scores compress. With per-analyzer caps and a shared decay constant, most real repos landed in a narrow high band and the score stopped discriminating. Removing the caps and letting bounded damages multiply restored a real 0 to 100 range.
- A sum cannot express "one collapsed category matters." An additive composite has a floor: four clean categories buy back a destroyed fifth. The geometric mean makes collapse visible in the headline while
HEALTH_FLOORkeeps the rest of the report legible. - Firing is not magnitude. In the old model a detector firing cost a fixed weight whether 5% or 45% of files deviated. The dominance deviation term makes the magnitude of inconsistency, not the fact of detection, carry the damage.
The trade-offs are real and accepted. The noisy-OR is sub-additive, so per-finding impacts do not sum to the total gain (hence consistencyGain and its documented bounds). A multiplicative composite is harder to explain than an average, which is partly why this chapter exists. And every one of these changes made old scores incomparable with new ones, which is exactly the problem the SCORING_VERSION mechanism absorbs.
If you change anything in this chapter's math (a constant, a factor, a gate threshold), bump SCORING_VERSION in src/scoring/engine.ts and add a history entry. The delta suppression and the one-time notice only work if the version string moves with the methodology.
docs/handbook/08-scoring.md, then rebuild with node scripts/build-handbook.mjsThe MCP Server: Drift Checks in the Agent Loop#
A batch scan reports drift after the code exists. By the time vibedrift scan flags a .then() chain in a repo that settled on async/await, the agent that wrote it has moved on, the diff is merged, and fixing the drift is a separate chore someone has to schedule. The MCP server moves the same checks to the moment that matters: while the agent is deciding what to write. An agent that asks "what is this repo's dominant error-handling pattern" before writing a handler, or "does this function already exist" before implementing it, does not introduce the drift in the first place. That is the product thesis, stated in the server's own instructions (src/mcp/server.ts): the local in-loop tools are free for everyone and answer conformance questions during coding; batch --deep --diff runs remain the recommendation for reviewing a finished change-set.
MCP (Model Context Protocol) is the standard by which coding agents like Claude Code call external tools. VibeDrift's server speaks it over stdio: vibedrift mcp (or node dist/mcp/server.js) starts a server named vibedrift that registers six tools. All logging goes to stderr, because stdout is the JSON-RPC channel; a stray console.log would corrupt the protocol stream.
Two layers: tools-core and the MCP adapter#
The tools are implemented twice-decoupled:
src/tools-core/is the channel-neutral core. Each tool is a plain async function (getIntentHints,getDominantPattern,checkFileDrift,findSimilarFunction,validateChange) exported fromsrc/tools-core/index.ts, callable from code-mode hosts, Agent Skills, or git hooks without any MCP machinery. Nothing intools-coreimports the MCP SDK, and that is not a convention but a tested invariant:test/unit/tools-core/no-mcp-coupling.test.tsfails the build if the coupling appears.src/mcp/is the stdio adapter. Each file insrc/mcp/tools/registers the corresponding corerunfunction on theMcpServerand wraps the plain result in the wire envelope.
The envelope (src/mcp/envelope.ts) is one shape for every tool: the structured result is serialized into a single text content block and mirrored as structuredContent, so machine-parsing agents and text-only clients both get the data.
The never-throw status contract#
Tools never throw to signal "no data". Every result carries a status from src/tools-core/result.ts:
export type Status = "ok" | "partial" | "stale" | "no_baseline" | "degraded";| Status | Meaning |
|---|---|
ok | Answered from a baseline that matches the working tree |
partial | Local answer plus an opt-in cloud deep check both contributed |
stale | Answered from a cached baseline that no longer matches the working tree |
no_baseline | No baseline could be built (empty dir or failed build); the result is an honest empty shape |
degraded | An opt-in deep check could not reach the cloud (not signed in, over budget, rate limited, network); the local result is still returned |
A no_baseline response carries NO_BASELINE_MESSAGE, which tells the agent how to fix it ("Run vibedrift scan once to build it") and to proceed without conformance data. The design goal is that an agent can always keep working: a tool error would derail the agent's turn, while a status lets it degrade gracefully.
Write-time tool results may additionally carry a NudgeHint: a gated, cooled-down FYI offering a deep scan (fires only when the user is signed in, after 8 or more write-time calls in a session, at most once per day, and only when the user has never deep-scanned or their last deep scan is older than 3 days; the timestamp lives in the global ~/.vibedrift/config.json, so it is per-user, not per-repo, and a deep scan of any repo resets it; src/tools-core/nudge.ts).
The six tools#
| Tool | Question it answers | Key inputs and outputs |
|---|---|---|
init | Set this repo up (config, ignore rules) | In: rootDir, optional exclude globs, applyDetectedExcludes, detectOnly. Out: config written to <repo>/.vibedrift/config.json, optional .vibedriftignore. Detection and application are separate: candidates are auto-detected but only written when explicitly requested; the tool never silently excludes files |
get_intent_hints | What conventions has the team declared? | In: rootDir. Out: hints parsed from CLAUDE.md, AGENTS.md, .cursorrules as {dimension, pattern, label, source, line, text, binding: true}. All hints are marked binding; declarations are the team's ground truth and override inferred patterns, so the parser's confidence tiers are deliberately dropped from this surface |
get_dominant_pattern | What does this repo actually do for dimension X? | In: rootDir, dimension (one of error_handling, imports, exports, async, naming, data_access, logging, auth). Out: {dominantPattern, consistency, examples} projected from the baseline vote; auth reads the granular securitySubVotes["Auth middleware"] instead of the collapsed security vote |
check_file_drift | Does this existing file fit the repo? | In: rootDir, filePath. Out: {fits, deviations[], more} capped at 3 deviations, each with {dimension, yourPattern, dominantPattern, consistency, fixHint} citing an exemplar file |
find_similar_function | Does something like this already exist? | In: rootDir, body, optional deep. Out: {found, matches[{relativePath, name, line, similarity}], more}; local LCS threshold 0.6, cap 20 |
validate_change | Would this proposed function drift or duplicate? | In: rootDir, targetPath, body, optional deep. Out: {ok, conflicts[], duplicateOf[], referenceFiles[], confidence}; the only tool that judges uncommitted code |
Dimension names in get_dominant_pattern map onto real drift categories (error_handling to return_shape_consistency, data_access to architectural_consistency, auth to security_posture, and so on). When no vote fired for a dimension, the tool reports it as consistent, with a consistency string of 100% annotated "no deviations detected"; when a vote exists but sits below the reliable-sample floor, the consistency string carries an explicit "treat as advisory" hedge.
Baseline cache mechanics#
Every tool except init and get_intent_hints answers from the persisted RepoDriftBaseline (src/core/baseline.ts): the per-repo cache of category votes, security sub-votes, intent hints, and the MinHash function index. A full scan takes 3 to 8 seconds; the baseline lets each tool call overlay one file or one function against the cached aggregate in well under the server's target budget of 500ms per call.
On disk it lives at ~/.vibedrift/baseline-cache/<sha256(rootDir)[:16]>.json, never inside the project. BASELINE_VERSION is currently 3 and is bumped whenever vote logic, the detector set, or the signature format changes. The cache key is a content merkle: SHA-256 over BASELINE_VERSION\n plus the sorted path:hash lines of every scanned file, so a version bump invalidates every cache at once.
getBaseline(rootDir) in src/mcp/baseline-provider.ts implements the serving policy:
- Serve from the in-process memory cache, else load from disk without checking freshness.
- Never scanned: lazy build. The first tool call on a fresh repo builds the baseline (a one-time 3 to 8 seconds), persists it, and returns
ok, so the MCP server works out of the box with no manual scan step. Concurrent first calls share one build via an in-flight promise map. Only a genuinely empty directory or a failed build yieldsno_baseline. - Version mismatch: rebuild once. A baseline built under an older
BASELINE_VERSIONis missing current vote shapes (for examplesecuritySubVotes), so it triggers a rebuild. A failed rebuild is remembered in afailedVersionRebuildset and not retried on every call; the old baseline keeps being served rather than hard-failing the session. - Content staleness: re-hash, do not rebuild. The provider re-hashes the working tree's known files into the same merkle key. On mismatch it returns
status: "stale"and still serves the cached baseline, because a synchronous rebuild would blow the latency budget.
The staleness check re-hashes only the files the baseline already knows about (a missing file hashes as "MISSING"). Files created after the last scan are invisible to it, so a brand-new file does not flip the status to stale. This is a documented v1 limitation in src/mcp/baseline-provider.ts.
The trade-off is explicit: an agent gets a slightly stale answer in milliseconds instead of a fresh answer in seconds, and the stale status tells it which one it got. One more coupling matters: when the init tool writes new exclusions, it deletes the persisted baseline and calls invalidateBaselineMem(rootDir), because the freshness check cannot notice that the file set itself changed.
validate_change in depth#
src/tools-core/tools/validate-change.ts is the pre-commit judge: the agent hands it a proposed function body plus the file it will land in, and gets back whether the change would drift from the repo or duplicate existing code, before the code is written to disk.
Dimension conflict checks#
Three dimensions are checked, defined in the DIM_CHECKS table: async_patterns, return_shape_consistency, and architectural_consistency. Each pairs a single-body classifier with the dimension's canonical label set, and each classifier is the same one the corresponding batch detector uses (classifyAsyncStyle, classifyReturnShapeLabel, classifyDataAccessLabel). The comment states the invariant this buys: the in-loop check can never disagree with the detector, because they are literally the same classification code.
The dominant pattern to compare against is resolved by effectiveDominant: the detector vote when its stored label is in this dimension's vocabulary, otherwise the team's highest-confidence declared intent hint. The declared fallback is what catches the first deviation in a fully consistent dimension: a repo with zero async findings has no vote, but a declared "use async/await" rule is still binding. (For architectural_consistency, which is a composite category, the vote is honored only when it is actually a data-access label; a dependency-injection vote stored under the same category does not silence a declared ORM rule.)
A conflict is emitted when the body classifies to a different label than the dominant, with a fixHint of the form Repo uses <dominant>; this change uses <yours>. plus either an exemplar file or the declaring line (for example (declared in CLAUDE.md:14)).
Duplicate check#
The proposed body runs through findSimilarToBody against the baseline's MinHash index at DUPLICATE_THRESHOLD = 0.8, stricter than the 0.6 the discovery tool uses, because introducing a near-clone is a stronger claim than "something vaguely similar exists". The target file's own entries are excluded from the index first, so editing an existing function never flags it as a duplicate of itself.
The hedged auth check#
checkRouteAuthDrift fires only when all of the following hold: the proposed body registers a mutating route with no visible per-route guard (classified by classifyRouteAuth, which reuses the batch AST route extractor), and the repo's own convention says auth is expected, either through the peer-majority "Auth middleware applied" sub-vote or a declared auth_required intent hint. Otherwise it returns null, honest silence, including the healthy case where there is nothing to compare against.
When it fires, the fixHint cites a truthful count ("Repo applies auth on 14 of 16 mutating routes") or the declaring line, offers the // @vibedrift-public annotation for intentionally public routes, and always appends the caveat: "router-level middleware is not visible to this in-loop check, so this is a hint, not a verdict." The caller forces confidence: "low" whenever this conflict is present, and keeps it low even when a later deep pass returns confident cloud findings, because a single-body check structurally cannot see router-scope middleware and must never pretend it can. A below-floor auth vote (too few routes to be reliable) is additionally hedged as advisory inside the hint.
The confidence hedge#
Output confidence is "low" whenever the judged dimension's own consistencyScore is under THIN_MARGIN = 75, just above the 70% dominance bar the detectors use. The reasoning is in the comment: the baseline is frozen, so a vote that barely cleared dominance could plausibly be tipped by the very change being validated, and the tool should not claim high confidence about a majority that thin. A declared-rule fallback has no vote and stays high; a declaration is binding regardless of current code.
Opt-in deep mode#
Passing deep: true adds a metered cloud pass (described here at the interface level; entitlement and billing are enforced server-side, and the tool schema documents the cost as 1/50 of a deep scan, hourly-capped). The fast path (src/mcp/deep-index.ts) embeds only the proposed function via /v1/embed and compares it by cosine similarity against the local per-repo embedding index, with a two-tier verdict: cosine at or above 0.9 ships directly as a confirmed semantic duplicate with no further cloud call; matches in the borderline band [0.72, 0.9) are sent, at most 8 of them with their stored bodies, to the cloud for an LLM confirm-or-reject, keeping only responses at or above 0.9; below 0.72 is dropped. If no embedding index can be built yet, the cold-start fallback (src/mcp/candidate-feeder.ts) re-extracts the repo's functions, samples up to 29 candidates with the batch deep-scan sampler, and sends query plus candidates to /v1/analyze, because sending the query alone would leave the pairwise detector repo-blind.
Deep hits set ok: false and status: "partial". Any cloud failure resolves to status: "degraded" with a human-readable reason (deepAnalyze in src/mcp/deep-client.ts never throws; it maps HTTP 402 to quota, 429 to rate_limited, and so on), and the local result stands.
A worked session#
Suppose a repo whose baseline votes async_patterns at dominantPattern: "async/await", consistency 91%, and the agent is about to add a lookup helper to src/services/order-service.ts written with promise chains:
function loadOrder(id: string) {
return db.orders.findOne(id).then((order) => {
return enrich(order);
});
}The agent calls validate_change with rootDir, targetPath: "src/services/order-service.ts", and that body. classifyAsyncStyle returns then_chains, whose display label .then() chains differs from the dominant async/await, so the response is:
{
"status": "ok",
"ok": false,
"conflicts": [{
"dimension": "async_patterns",
"dominantPattern": "async/await",
"yourPattern": ".then() chains",
"fixHint": "Repo uses async/await; this change uses .then() chains. See src/services/user-service.ts."
}],
"duplicateOf": [],
"referenceFiles": ["src/services/user-service.ts", "src/services/cart-service.ts"],
"confidence": "high"
}Confidence is high because 91 clears the 75 thin-margin bar. The agent rewrites the helper with async/await, calls validate_change again, gets ok: true, and writes the file. The drift never existed, which is the entire point of the in-loop design: the batch scanner would have caught the same conflict with the same classifier, but only after it was committed.
docs/handbook/09-mcp-tools.md, then rebuild with node scripts/build-handbook.mjsOutput Surfaces#
A scan produces one ScanResult; everything the user sees is a renderer over that object. The renderers live in src/output/ and share two rules. First, they only render: no renderer recomputes scores or mutates findings, so every surface shows the same facts. Second, they are honest about scope: when a number covers less than it appears to, the copy says so instead of letting the reader assume.
Terminal rendering#
renderTerminalOutput in src/output/terminal.ts composes the full authenticated view: update banner, score section, scan-over-scan diff banner, category bars, peer percentile, fix plan, drift findings grouped by category, the hygiene pane, deep-scan sections when present, and closing calls to action. renderBriefOutput is the unauthenticated variant, renderConciseSummary the authenticated three-fix summary, and renderJsonOutput the machine shape for --json. On a deep scan the concise summary also carries a short AI block (renderConciseAiSummary) showing whichever deep artifacts the scan actually produced: the coherence grade (fetched on paid plans only), the AI summary line, the top AI finding, and the AI-validated finding count (findings tagged ml, the same filter as the HTML deep section), plus a pointer to the full analysis. The block is presence-gated per artifact, so a free-tier deep scan surfaces its results too and a non-deep scan renders byte-identically to before.
The score block#
renderCategoryBars prints each category as score/max with a 20-character block bar, colored green at 80% of max and above, yellow at 50%, red below. A category with no findings and no relevant files renders as "N/A" with an explanation rather than a perfect score, and when any category is N/A the composite line appends a scope note built by compositeScopeNote: (over N of M categories). The comment on that function states the invariant: the headline can never silently imply a full verdict over categories that were not measured.
Two other honesty rules render here:
- The Hygiene Score is printed as a separate scalar explicitly labeled as not part of the Vibe Drift Score, keeping the drift-vs-hygiene split visible at the surface.
- Under the Security Consistency bar a permanent disclaimer renders: "consistent ≠ safe: measures how uniformly this repo applies its own auth and validation patterns, not the absence of vulnerabilities". VibeDrift measures drift, and this line stops the security category from being misread as a vulnerability audit.
Hedged security copy#
Some auth findings are produced by body-signature analyzers (Python, Go, Rust) that could not confirm whether an auth hook applies. Those producers append a stable "Double check" hedge to the recommendation naming the unverified mechanism. The terminal renderer detects them with isHedgedAuthFinding and swaps the confident consequence line ("Unprotected routes may be exposed in production") for a hedged one parsed back out of the recommendation: "The <noun> (<names>) may already authenticate some of these routes, double check it before treating them as unprotected". The hedge also renders as an extra yellow line under the finding. Every confident security finding stays byte-identical; only the genuinely unconfirmed ones are softened.
The security-floor badge#
src/output/floor-badge.ts implements a render-only warning line: "Security floor: <reasons>. Fix before shipping (does not change the score)." It trips on security-floor analyzer findings (committed secrets, disabled TLS verification) or on codedna-taint findings whose sink is code or command injection (via the INJECTION_SINK_LABELS export described in the Code DNA chapter).
hasFloorTrip never touches compositeScore or the grade. That is a locked constraint with a dedicated grade-invariance test: the floor is a shipping gate rendered next to the score, not a score input. A repo can be perfectly self-consistent (high drift score) while carrying a committed secret, and the two facts are reported independently.
The fix plan#
The fix plan selects top findings by consistencyImpact, the score gain from fixing that finding, and filters out anything below FIX_PLAN_MIN_IMPACT = 0.05 (src/output/fix-plan-select.ts) so no line ever renders as "+0.0pts". In drift-first mode, ordering uses findingPriority: security findings weigh 10 times severity, architectural and intent findings 8, duplication 6, dependency and error-handling 5, everything else 2. The plan footer renders a projected score after fixes via estimateScoreAfterFixes, rounded to the nearest 5 and phrased as an approximation ("projected ~85/100"), because a point-exact projection would overstate the model's precision.
The diff banner#
When a comparable previous scan exists, a "Since last scan" banner renders the relative age, resolved and new counts split into drift versus hygiene, the top 3 new drift findings, and the score delta. Two suppression rules protect it from lying:
- If the previous scan was produced under a different scoring version, the banner is silently suppressed: both the delta and the resolved/new counts would be artifacts of the methodology change, and the one-time scoring notice (below) explains what happened instead.
- If the previous scan predates the diff-capable history schema, the banner says so and treats this scan as a fresh baseline rather than claiming everything is "new".
HTML report#
renderHtmlReport in src/output/html.ts builds a fully self-contained document: all CSS and JS inline, no external requests needed to read it. It renders in two modes: summary (header, hero score, category breakdown, fix-plan widget, drift concentration, footer) and detailed (adds the codebase-intent section, intent-coherence heatmap, the full drift findings library, a per-file ranking accordion, pattern consensus, hygiene, and the deep-scan section when a deep run happened).
The embedded tail script implements a persisted dark/light theme toggle (applied pre-paint so there is no flash), copy-to-clipboard for embedded AI fix prompts (the prompts are embedded only for paid plans via buildEmbeddedPrompts(result, isPaid)), client-side CSV export built from the embedded window.__VIBEDRIFT_DATA, print-to-PDF, and a sticky mini-header. Grade mapping is the same as everywhere else: A at 90% and above, B at 75, C at 50, D at 25, else F. The same hasFloorTrip from the terminal drives an equivalent floor chip.
When the report was generated for a logged-in scan (opts.scanId set), the page embeds a one-shot beacon that POSTs {scan_id, opened_at} to /v1/beacon/report-open on load; the local/cloud boundary chapter covers exactly what that carries.
CSV#
renderCsvReport in src/output/csv.ts emits a multi-section CSV: metadata, CATEGORY SCORES and DRIFT SCORES, DRIFT FINDINGS, the Code DNA sections (SEMANTIC DUPLICATES, OPERATION SEQUENCE MATCHES, TAINT FLOWS, DEVIATION ANALYSIS, PATTERN DISTRIBUTIONS), ALL FINDINGS, PER-FILE SCORES sorted ascending by score, and DEEP ANALYSIS INSIGHTS. Consistency with the composite is enforced at render time: when the composite shows security as N/A, the security row is suppressed from the score sections so the two surfaces never contradict, and below-floor security findings were already excluded at the scan source.
DOCX#
renderDocxReport in src/output/docx.ts produces a real OOXML .docx. A .docx file is a ZIP archive of XML parts, so the renderer includes a minimal hand-rolled ZIP writer: deflateRawSync from node:zlib for compression, a local CRC32 implementation, and manually assembled local file headers, central directory, and end-of-central-directory records. No document library is pulled in; the writer is about 70 lines and covers exactly the subset OOXML needs. The document sections mirror the CSV: title page, score table, intent, drift, Code DNA, per-file table, findings table, deep insights, footer.
context.md, patterns.json, and fix plans#
writeContextFiles in src/output/context-md.ts, triggered by --write-context (requires a free account), writes committable files into <repo>/.vibedrift/:
context.md: a human-and-agent-readable digest. Header comment marking it auto-generated and safe to commit, with the refresh command. Then: score and grade, dominant language, file and line counts; a "Dominant patterns in this codebase" section with one bullet per drift category (dominant pattern,count/total files, an exemplar path); "Drift items currently open" listing the top 10 byconsistencyImpactwith their point gains; a "Recent trajectory" section (previous-scan age, score delta, resolved/new counts, top new findings) when a comparable diff exists; and an "If you're an AI agent working on this codebase" section that instructs agents to match the dominant patterns and re-run the CLI after changes.patterns.json: the machine shape of the same votes: per category{dominantPattern, dominantCount, totalRelevantFiles, consistencyScore, dominantFiles, deviatingFiles}plus generator metadata and the score.fix-plan.mdandfix-prompts.md: on paid plans, a full fix plan and per-finding prompt blocks; on free, an upsell block explaining what would be there.
The writer warns when .vibedrift/ is gitignored, since the files exist to be committed and shared with the team and with agents.
The --inject-context managed block#
Committing context.md only helps agents that go looking for it. --inject-context (src/output/inject-context.ts) pushes the content into the files agents already read, by default CLAUDE.md, inside a managed block:
<!-- vibedrift:context:start (auto-generated, do not edit by hand) -->
...context.md content...
<!-- vibedrift:context:end -->upsertManagedBlock makes the operation idempotent: when both markers already exist, the block is replaced in place, and the text before and after survives untouched; otherwise the block is appended with correct separators; a missing target file is created. Re-running never duplicates the block or corrupts surrounding content, which is the property that makes it safe to wire into a refresh loop. The injected body is prefixed with a one-line preamble telling agents the block is regenerated by vibedrift --write-context --inject-context and should be read before editing code. CLI wiring is in src/cli/commands/scan.ts.
History diffs and trajectory#
Scan history lives in ~/.vibedrift/scans/<projectHash>/scan-<timestamp>.json (src/core/history.ts), with retention capped at 10 scans per project. The schema is versioned separately from the scoring methodology:
schemaVersion(HISTORY_SCHEMA_VERSION = 3) describes the structural shape. v1 mixed scores, v2 split drift from hygiene, v3 added finding digests, which is what makes diffing possible.scoringVersion(currently"v11", exported fromsrc/scoring/engine.ts) describes the methodology the numbers were computed under.
Digest stability#
A finding digest is sha256(analyzerId | file | lineBucket | normalizedMessage) truncated to 16 hex characters. Two normalizations keep digests stable across harmless edits: every integer in the message is replaced with N, so "3 occurrences" and "4 occurrences" match, and line numbers are bucketed by floor(line / 3), so a finding survives the surrounding code shifting by a couple of lines. Drift-finding digests are line-less (keyed on detector, category, subcategory, dominant pattern, and normalized text) because drift findings are project-scoped rather than positional. Saves cap digests at 200 generic and 100 drift entries.
The diff engine#
diffScans(previous, current) in src/output/history-diff.ts classifies digests into resolved (in previous, not current), new, and persistent, independently for generic and drift findings, with deterministic ordering. Score delta is a plain difference; the header documents that the engine deliberately does not attribute the delta to individual findings, because that causal projection is lossy and belongs, if anywhere, in a UI layer that can label it as an estimate. A previous scan below schema v3 is marked incomparable: true and everything lands in "new", since without digests nothing can honestly be claimed resolved.
Cross-version delta suppression and the one-time notice#
When previous.scoringVersion differs from the current SCORING_VERSION, delta computation is refused: the numbers were produced under different formulas and subtracting them yields a misleading result. Instead of a per-scan version banner (an earlier design that was rejected as noise), src/core/scoring-notice.ts shows a single one-time notice: "We refined how the Vibe Drift Score is calculated this release. Your existing scores are kept as they were; the update applies to new scans. What changed → https://vibedrift.ai/releases". shouldShowScoringNotice shows it once per version change and skips brand-new users entirely (nothing to re-align, so the version is recorded silently). No internal version string is ever shown to the user.
docs/handbook/10-outputs.md, then rebuild with node scripts/build-handbook.mjsThe Local/Cloud Boundary#
VibeDrift makes network calls by default. This chapter is the precise inventory: every endpoint the CLI can talk to, exactly what crosses the wire in each case, when it fires, and how to turn each of them off. The posture throughout the codebase is honest disclosure rather than a blanket "we never phone home" claim, because the latter would be false: there is an anonymous usage beacon and a daily update check, both on by default, both documented to the user on first run, and both switchable off.
What never leaves the machine#
Regardless of plan or sign-in state, the following stay local, always:
- Full source files. No code path uploads a whole file. The deep scan sends bounded function snippets (detailed below); everything else sends metadata or nothing.
- Absolute paths. Payloads carry paths relative to the scan root; the scan root itself is represented only as a SHA-256 hash. The sanitizer that prepares scan uploads (
src/ml-client/sanitize-result.ts) stripscontext.rootDirand rewrites any absolute path relative. - Embedding vectors.
/v1/embedreturns vectors that are persisted only locally, in~/.vibedrift/embedding-index/(directory mode 0700). The client documentation insrc/core/embedding-index.tsstates it directly: your code's vectors never sit on the server. - The baseline, scan history, and findings cache, all under
~/.vibedrift/, keyed by path hash. - Everything, when you ask for it.
--local-onlygates all network calls. The comment insrc/cli/commands/scan.tsenumerates what that covers: the auth banner, deep analysis, the scan log, fix-prompt synthesis, and the anonymous beacon. Zero egress.
Nothing scan-related is written inside the user's project except the opt-in .vibedrift/ files, .vibedriftignore, and the managed block the opt-in --inject-context flag writes into CLAUDE.md (or the target file you name).
Device auth flow#
Signing in uses an OAuth-style device flow against the API (default https://vibedrift-api.fly.dev, overridable by flag, VIBEDRIFT_API_URL, or config). Implemented in src/auth/api.ts:
POST /auth/devicewith body{client_id: "vibedrift-cli"}. The response carriesdevice_code,user_code,verification_uri,verification_uri_complete,expires_in, andinterval.- The CLI opens the verification URL in a browser (refusing in CI and non-TTY environments, honoring
$BROWSERandVIBEDRIFT_NO_BROWSER=1) while pollingPOST /auth/pollwith{device_code}until the response isauthorized(carryingaccess_token,email,plan,expires_at),denied, orexpired. - The token lands in
~/.vibedrift/config.jsonwith file mode 0600 in a 0700 directory, and the mode is re-asserted on every write (src/auth/config.ts), so a shared machine's other users cannot read it.
Token resolution priority is explicit flag, then the VIBEDRIFT_TOKEN environment variable, then the config file. GET /auth/validate checks a token, POST /auth/revoke is logout. When the CLI displays a token it shows only the first 12 characters.
Deep scan wire format#
A deep scan (--deep) POSTs one MlAnalyzeRequest to /v1/analyze (src/ml-client/client.ts, 90-second timeout to absorb model cold starts). What is actually in it, per runMlAnalysis in src/ml-client/index.ts:
- At most 30 function snippets, each truncated to 60 lines.
sampleFunctionsForMl(src/ml-client/sampler.ts) scores every extracted function and takes the top 30: members of the ambiguous similarity band get +100 (guaranteeing the pairs the cloud judge exists to resolve survive the cap), entry-point files +10, +3 per existing finding on the file, up to +5 by size, +3 for handler/service paths. Bodies beyond 60 lines are cut with a truncation marker. Each payload carries{id, name, file, body, line_start, line_end, language}wherefileis the relative path. - At most 20 deviation payloads, built from Code DNA deviation justifications and architectural drift findings, each with the pattern mapped into the API's trained deviation types and its snippet capped at 200 characters.
- Project identity, hashed.
project_hashis the SHA-256 of the absolute root directory, computed client-side so the server never sees the path itself (src/ml-client/project-name.ts).project_nameis a human label autodetected from package.json, Cargo.toml, go.mod, or pyproject.toml, falling back to the directory basename;--project-nameoverrides it and--privatereplaces it with an anonymizedpriv<hash>label. - Metadata: language, file count, optional local score and grade hints so the dashboard can render history without re-scoring, and
defer_persist. The schema also defines an optionalsourcefield, sent as"mcp"by the in-loop deep check; a full--deepscan omits it (only the embedding endpoint tags requests"cli").
The verbose log states the boundary in one line: no full files transmitted, only function snippets and structural metadata. The in-loop MCP deep path is bounded even tighter: the candidate feeder caps at 29 candidates plus the query, and the embedding-index path sends at most 8 borderline candidates for LLM confirmation.
/v1/embed keeps vectors local#
The embedding endpoint receives, per function, only {id, body, language} plus a source tag, chunked 48 functions per request (src/ml-client/embed-client.ts). The client-side contract documented in the code: the server computes embeddings transiently and stores nothing; the returned vectors are written to the local index under ~/.vibedrift/embedding-index/<hash>.json and invalidated by baseline-key mismatch. Bodies sent for embedding are truncated to the same 60 lines the deep scan uses.
Authenticated scan logging and report upload#
When signed in (and not --local-only), every scan is logged to POST /v1/scans/log after the local pipeline finishes, silently skipping on any failure. The payload contains metadata counts, optionally the rendered HTML report (dropped entirely if over 1.5MB), and result_json: the sanitized ScanResult described above (root dir stripped, paths relativized, raw file contents and AST nodes dropped, the files list reduced to {relativePath, lineCount, language}, per-file scores summarized to histograms). Oversized payloads are progressively trimmed toward a 9MB target by dropping the heaviest sections first (Code DNA functions, then location snippets, then capped deviating-file lists, and so on), and the upload is aborted outright above 24MB.
Two paid synthesis endpoints exist at the interface level: POST /v1/fix-prompts (sends up to 10 findings, each with the deviating snippet and up to 3 reference snippets of at most 60 lines) and POST /v1/coherence (sends drift findings, dominant patterns, and confirmed deep findings for the deep-scan report). Both fail soft. PUT /v1/scans/{id}/report uploads the rendered HTML (at most 1MB) for dashboard display.
The telemetry beacon#
src/telemetry/beacon.ts sends one anonymous POST to /v1/beacon/scan after every scan, signed in or not, fire-and-forget with a 3-second timeout; all failures are silently ignored. The payload is exactly these 11 fields, defined in ScanBeaconPayload:
| Field | Content |
|---|---|
language | dominant language string, or null |
file_count | number of files scanned |
loc | total lines scanned |
scan_time_ms | scan duration |
cli_version | CLI version string |
is_deep | whether --deep was requested |
has_git | whether git metadata was available |
has_intent_hints | whether any CLAUDE.md/AGENTS.md hints were parsed |
finding_count | number of findings |
score | composite score |
authed | whether a token was present, as a derived boolean only |
No code, no file paths, no identifiers, no user id, no token. The authed docstring is explicit that the boolean carries no token and no identifier, so the event stays anonymous; it exists so the dashboard can split signed-in from signed-out usage.
There are three ways to turn it off (isTelemetryEnabled):
vibedrift telemetry disable, which persiststelemetryEnabled: falsein~/.vibedrift/config.json.- The
VIBEDRIFT_TELEMETRY_DISABLEDenvironment variable, any non-empty value. --local-only, which as noted gates every network call, the beacon included.
The same toggle also governs the once-daily npm update check, so disabling telemetry disables both.
The beacon is disclosed, not hidden. On the first scan, a notice prints to stderr (skipped under --local-only and --json) stating that VibeDrift sends an anonymous usage beacon after each scan, listing the field categories, noting the daily npm update check, naming all three opt-outs, and linking https://vibedrift.ai/privacy. The acknowledgment is persisted so the notice shows once.
A second, narrower beacon exists for report analytics: the HTML report generated for a logged-in scan embeds a script that POSTs {scan_id, opened_at} to /v1/beacon/report-open once on load. It only exists when the report carries a scan_id, meaning logged-in scans; a report from an anonymous scan embeds nothing.
Endpoint summary#
| Endpoint | When | Carries | Off switch |
|---|---|---|---|
POST /v1/beacon/scan | after every scan | the 11 anonymous fields above | telemetry disable, env var, --local-only |
| npm registry (update check) | at most daily | standard npm metadata request | same toggle as the beacon |
POST /auth/device, /auth/poll, GET /auth/validate, POST /auth/revoke | explicit sign-in/out | client id, device code, token | do not sign in |
POST /v1/analyze | --deep, or MCP deep checks | ≤30 function snippets x 60 lines, ≤20 deviations, hashed project identity, metadata | do not use deep features; --local-only |
POST /v1/embed | building/querying the local embedding index | {id, body, language} per function, 48 per chunk | same |
POST /v1/scans/log, PUT /v1/scans/{id}/report | authenticated scans | sanitized result, optional HTML report | sign out or --local-only |
POST /v1/fix-prompts, /v1/coherence | paid synthesis features | finding snippets, dominant patterns | do not enable those features; --local-only |
POST /v1/beacon/report-open | opening a logged-in scan's HTML report | {scan_id, opened_at} | reports from anonymous scans embed nothing |
Server-side behavior (what the API stores, billing enforcement) is implemented in the separate API service and is outside this repo; the claims in this chapter are scoped to what the client sends and expects, which is fully auditable in the paths cited above.
docs/handbook/11-cloud-boundary.md, then rebuild with node scripts/build-handbook.mjsTesting, Calibration, and CI#
A drift detector has two failure modes a normal unit test cannot see: it can be wrong about real code (precision and recall), and it can be numerically unresponsive (drift rises but the score barely moves). The test suite is therefore layered: conventional unit and integration tests for behavior, a calibration harness with enforced accuracy gates for detector quality, and a manual eval harness for the expensive questions. This chapter maps the layers and ends with the workflow a contributor should follow to prove a detector change is safe.
Test suite layout#
The runner is vitest; vitest.config.ts includes every test/**/*.test.ts, so npm test runs the unit tests, the integration tests, and the calibration-directory test files in one pass.
test/
unit/ mirrors src/: analyzers, auth, calibration, cli, codedna,
core, drift, intent, mcp, ml-client, output, scoring,
telemetry, tools, tools-core, utils
integration/ drift.test.ts, intent-hints.test.ts, mcp.test.ts,
scan.test.ts, temporal-pivot.test.ts
calibration/ the harnesses (below) + security-{python,go,rust}.test.ts
eval/ unit tests for the eval harness (fixtures, measure,
orchestrate, runners, stats)
fixtures/ seven small end-to-end repos
helpers/ drift-tree.ts (shared fixture-tree builder)The fixture repos are clean-project, messy-project (empty catch blocks, a TODO cluster, CJS/ESM mixing, duplicate fetchers, env-var drift), drift-project (handlers, services, and repositories with injected convention splits), empty-project, and one each for go-project, python-project, rust-project. Integration tests scan these end to end; test/integration/mcp.test.ts spawns the actual stdio server binary.
Some tests are guard tests for architectural invariants rather than behavior: test/unit/tools-core/no-mcp-coupling.test.ts fails if src/tools-core/ ever imports the MCP SDK, and the floor-badge suite pins that a security-floor trip never changes the grade.
The calibration harness#
test/calibration/ (see its README) holds two harnesses that run over a synthetic labeled corpus: drift is injected into a uniform generated baseline, so ground truth is exact and mechanical, with no human labeling.
npm run calibrate: the accuracy harness#
test/calibration/precision-recall.ts generates the uniform baseline (baseline.ts), applies one injector at a time (injectors.ts), derives ground-truth labels by diffing which files were mutated, scans, and reports precision, recall, and F1 per detector against that ground truth. INJECTOR_CATEGORY maps each injector to the category expected to catch it: naming to naming_conventions, architectural and error-handling to architectural_consistency, security to security_posture, and the floor injector to the security-floor analyzer. INJECT_RATE = 0.34 mutates roughly 2 of every 6 eligible files: a clear minority, so the injected drift is unambiguous against the dominant pattern.
One row is an enforced gate, not a report: the security-floor row must hold precision at or above FLOOR_PRECISION_GATE = 0.95 or the run fails. The floor badge tells a user "fix before shipping", and that copy is only defensible if a floor trip is almost never a false alarm.
Results land in test/calibration/reports/latest.json. The README calls these per-category rows "trend rows": they are compared across runs to catch accuracy regressions, which makes the file the before/after artifact for any detector change. Categories that fire on the templated baseline's own structure (semantic_duplication, dead code, phantom_scaffolding; the near-identical, consumer-less generated handlers legitimately trip them) are reported separately as un-measured rather than pretending the synthetic corpus can score them. The README also records a known finding from the first baseline honestly: naming_conventions scores F1 1.00, but architectural_consistency recall is 0.00 on the synthetic corpus, flagged for follow-up rather than hidden.
npm run calibrate:monotonic: the responsiveness gate#
test/calibration/run.ts generates the baseline, injects drift at rates 0, 10, 25, 50, 75, and 90 percent, and pushes each variant through the real pipeline: context building, parsing, analyzers, drift detectors, and computeScores, not a mock of any stage. It then asserts two properties and exits 1 on violation:
- Monotonicity: the composite score never rises as injection increases (tolerance 0.5 points).
- Responsiveness: each 25-percentage-point injection step, 25 to 50 and 50 to 75, must drop the composite by at least
REQUIRED_DROP_PER_25PCT = 3.0points.
The README is candid that this is the weaker of the two harnesses (almost any threshold choice passes a monotonicity check), which is why it is kept as a pre-publish smoke test while the precision/recall harness is the accuracy authority. It exists to catch scoring regressions of the shape "one category silently dominates another", which shipped once before this gate existed.
The S0-S11 security fixture families#
The per-language AST security extractors (src/drift/security-ast-python.ts, -go.ts, -rust.ts) get their own enforced precision/recall gates: security-python.test.ts, security-go.test.ts, and security-rust.test.ts in test/calibration/, each with a fixture generator. These run on every npm test, not just in a manual calibration pass, because auth-detection regressions are the most expensive kind to ship.
Each language covers a scenario grid, with per-language differences: Python covers S0 through S11; Go covers S0 through S8 plus the cross-file S10/S11 (it has no S9); Rust covers S0 through S8 plus its own Rust-specific S9, a guarded-403 produce-gate scenario, and has no cross-file S10/S11 yet. Each scenario computes precision and recall explicitly against planted ground truth:
| Scenario family | What it pins |
|---|---|
| S0 | Recognition self-check: every planted route is counted before anything else, so a recall regression fails with a count instead of silently vanishing from a vote several layers down |
| S1, S2 | The primary dominance vote: one planted deviator among an authed majority, precision and recall 1.0 |
| S3 | The uniform-auth-gap fallback: all routes stripped at once, the primary vote goes silent, the gap must fire |
| S4 | Negative control with non-vacuity: route extraction is asserted before zero-findings, because silence only proves something once the routes were actually seen |
| S5 | Uniformly-authed control: zero findings on every axis |
| S6-S8 | Body-signature collisions: an auth-sounding hook that merely emails must not bless routes; a boring-named hook that really authenticates must |
| S9 (Python: the methods-variable pattern resolving correctly; Rust: the guarded-403 produce-gate) | Language-specific resolution mechanics |
| S10 (Python, Go) | Cross-file positive: route files importing an auth hook defined in another in-repo file resolve it and bless the routes |
| S11 (Python, Go) | Cross-file negative: the same routes importing the hook from outside the repo stay hedged, never silently blessed |
S10 and S11 encode the hedging contract described in the MCP and output chapters: an auth mechanism the scanner can actually resolve inside the repo may confidently bless routes; one it cannot see stays a hedged "double check" finding. Rust additionally contributes an isolated security_posture_rust trend row to npm run calibrate (first-run precision 1.00, recall 1.00). Language-specific rows are deliberately kept separate, never merged into the shared baseline row, so a Rust regression cannot hide inside a blended average.
The eval/ harness#
eval/ holds the expensive, non-deterministic experiments that do not belong in CI:
npm run eval(eval/run.ts): the drift-delta eval. It is manual and metered: it needs anANTHROPIC_API_KEYand runs real agent tasks overeval/fixtures/repos, comparing arms in which the agent gets nothing, gets the generated context file, or gets the MCP tools (whose baselines are pre-built for that arm).EVAL_TRIALSdefaults to 3. It writes timestamped reports toeval/reports/; a positive delta means the agent with VibeDrift introduced less drift than the control.eval/discrimination/run.mjs: the score-discrimination harness. It shallow-clones the repos listed inrepos.json, runs the actual built CLI with--local-only --json, and prints a score-sorted table with clean-versus-messy separation and per-repo top analyzers. It measures only and changes nothing; use it when a scoring change should widen (or must not narrow) the separation between known-clean and known-messy repos.eval/recall/: stored artifacts (bands, functions, pairs, verdicts) from reimplementation-recall audits, kept so the audits are reproducible.eval/context-token-benchmark/: a pre-registered A/B harness with its own package.json and PRE-REGISTRATION.md.
CI workflows#
.github/workflows/ci.yml runs three jobs on pushes to main and on pull requests:
- build-and-test: a Node 20.x and 22.x matrix running
npm ci,npm run lint,npm run typecheck,npm run build,npm test. Everything enumerated above undernpm test, security grids included, gates every PR on both Node lines. - drift-scan: dogfooding. CI builds the CLI and scans this repository with it (
node dist/cli/index.js . --local-only --no-cache --format terminal). The run is currently informational: the--fail-on-scorethreshold is disabled, with an in-file comment explaining that a scoring-version change moved the self-score below the old floor and the gate stays off until recalibration sets a new one. The scan output remains visible in the logs. Note the--local-only: CI performs no network calls. - secret-scan: gitleaks (pinned version) over the pushed ref's own history. The
--log-opts="HEAD"scoping is deliberate and documented in the workflow: without it, a full-depth checkout would scan every branch, and an unrelated branch's extracted OSS test fixture (a template private-key block holding no real key) could fail main's build. Each branch is still scanned by its own PR run.
discord-release.yml is release plumbing, not a gate: it posts the release notes to Discord when a GitHub Release is published. The npm side has its own belt: prepublishOnly reruns lint, typecheck, test, and build, so a broken package cannot be published even manually.
Proving a detector change is safe#
The workflow that follows from these layers, for anyone modifying a detector, classifier, or scoring path:
- Write or update the unit tests first. Every detector has a suite under
test/unit/drift/ortest/unit/analyzers/; a behavior change without a test change is a red flag in review. - Run
npm test. This already includes the S0-S11 security grids, the guard tests, and the integration scans of the fixture repos. - Run
npm run calibrateand diff the trend rows intest/calibration/reports/latest.jsonagainst the previous run. Precision or recall dropping on a category you did not intend to touch is the regression signal this harness exists to catch. The security-floor precision gate must hold at 0.95 or the run fails on its own. - Run
npm run calibrate:monotonicif the change touches scoring or finding weights: the composite must still fall at least 3 points per 25% injected drift. - For changes that could shift real-repo scores, run the discrimination harness and check that clean/messy separation did not narrow.
- If the change alters documented behavior, fix the affected handbook chapter in the same PR.
If a detector change is accurate on the synthetic corpus, holds the security gates, keeps the score responsive, and does not compress real-repo separation, it is as proven as local tooling can make it; CI then re-checks the first two on both supported Node versions.
docs/handbook/12-testing-and-calibration.md, then rebuild with node scripts/build-handbook.mjsExtending VibeDrift#
This chapter is recipes: the exact files to touch, in order, for the four most common extensions. One rule governs all of them, and it comes from CONTRIBUTING.md: a new signal must be grounded in a baseline it deviates from (a dominance vote, a similarity measure, a taint flow). A raw heuristic with no peer group can still ship, but it is hygiene-kind by definition and will never move the Vibe Drift Score. Decide which side of that line your idea is on before writing code.
Recipe 1: add a static analyzer#
A static analyzer examines files independently and returns findings. The contract is small:
// src/analyzers/base.ts
export interface Analyzer {
id: string;
name: string;
category: ScoringCategory;
requiresAST: boolean;
applicableLanguages: SupportedLanguage[] | "all";
version?: number; // bump when logic changes; part of the cache key
analyze(ctx: AnalysisContext): Promise<Finding[]>;
}- Create
src/analyzers/<id>.tsimplementingAnalyzer. Usefile.tree(a tree-sitter tree) when it exists and fall back to regex when it does not: parsing failures return an absent tree rather than aborting, and every shipped analyzer degrades gracefully (seenaming.tsorcomplexity.tsfor the pattern). - Register it in
createAnalyzerRegistry()insrc/analyzers/index.ts. Position matters: findings are reassembled in registry declaration order after the analyzers run concurrently, so the registry order is part of the deterministic-output contract. - Register the analyzer id in
CATEGORY_CONFIGinsrc/scoring/categories.ts, choosing the scoring bucket (architecturalConsistency,redundancy,dependencyHealth,securityPosture,intentClarity) and thekind. - Emit findings in the house shape (
src/core/types.ts):analyzerId,severity(info|warning|error),confidence(0 to 1),message,locations,tags. Aggregate: shipped analyzers emit one rolled-up finding per phenomenon (often per directory or per project) with locations as evidence, and cap runaway lists the waycomplexity.tscaps its per-tier findings. A finding per offending line drowns the report. - Bump
versionwhenever you change the logic. It feeds the findings-cache key (src/core/findings-cache.ts), so a bump invalidates stale cached output; forgetting it means users with warm caches keep seeing the old findings. - Add
test/unit/analyzers/<id>.test.ts(vitest,describe/it). For end-to-end coverage, the small fixture repos undertest/fixtures/(clean-project,messy-project, per-language projects) are scanned by the integration suite; extend one if your analyzer needs a realistic corpus. - Run the gates:
npm run lint,npm run typecheck,npm test,npm run build.
Unregistered analyzer ids silently default to hygiene (getAnalyzerKind in src/scoring/categories.ts). That default is deliberate, so an unknown id can never contaminate the drift composite, but it means step 3 is not optional if you intend the analyzer to be drift-kind.
Recipe 2: add a cross-file drift detector#
Detectors live one level up from analyzers: they see all files at once and vote.
// src/drift/types.ts
export interface DriftDetector {
id: string;
name: string;
category: DriftCategory;
detect(ctx: DriftContext): DriftFinding[];
}- Create
src/drift/<name>.tsimplementingDriftDetector. ItsDriftFindings must carry the vote arithmetic:dominantPattern,dominantCount,totalRelevantFiles,consistencyScore(dominant/total × 100),deviatingFileswith line-level evidence, and ideallydominantFiles(up to 3 exemplars, which power fix prompts and the MCP tools' fix hints). - Add the new category to the
DriftCategoryunion and toDRIFT_WEIGHTSinsrc/drift/types.ts.DRIFT_WEIGHTSis typedRecord<DriftCategory, number>, so the compiler forces the entry; note the weight is the report bar's max score, not a composite weight. - Register the detector in
createDriftDetectors()insrc/drift/index.ts, and add the category to theDriftScoresinterface and the category list insidecomputeDriftScoresin the same file. - Register the id
drift-<category>inCATEGORY_CONFIG(src/scoring/categories.ts) withkind: "drift"under the scoring bucket it should dent. - Build on the vote machinery in
src/drift/utils.tsinstead of reimplementing it:buildDirectoryScopedVote(minimum group size 3, dominance threshold 0.7, temporal weighting, intent-hint seeding), theentropyGatewithnoConventionFindingfor the "no norm exists" case, andisAnalyzableSourceto exclude tests, fixtures, and config files from the vote. If your detector measures a count phenomenon (pairs, orphans) rather than a peer ratio, setcountBased: trueso the scoring engine routes it through its density branch instead of misreadingconsistencyScoreas a deviation rate. - Tests:
test/unit/drift/<name>.test.tsfor the detector, and checktest/unit/drift/drift-finding-to-finding.test.tsandtest/integration/drift.test.tsstill pass. If the category can be injected synthetically, add an injector totest/calibration/injectors.tsand map it inINJECTOR_CATEGORYintest/calibration/precision-recall.tssonpm run calibratemeasures its precision and recall.
driftFindingToFinding (src/drift/index.ts) derives the routing analyzerId from the typed driftCategory as drift-<category>, never from the freeform detector string. Skipping step 4 does not error; the finding just defaults to hygiene and the detector never affects the headline score. This exact wiring gap once excluded most detectors from the composite, which is why the invariant is documented in the conversion function itself.
Recipe 3: add a language to Security Consistency#
Security Consistency (chapter 06) votes on whether routes carry the security properties their peers carry. Each language has its own AST extractor: src/drift/security-ast.ts (JS/TS), security-ast-python.ts, security-ast-go.ts, security-ast-rust.ts. Precondition: the language must already be parsed by the core pipeline (SupportedLanguage and the extension map in src/core/language.ts, grammar loading in src/utils/ast.ts).
- Write
src/drift/security-ast-<lang>.tswith route extraction: recognize the ecosystem's route registrations (verb calls, decorators, attribute macros), resolve receivers structurally first (assignments from known router constructors) with a naming-convention fallback, and gate paths on a leading-slash string literal socache.get("user:1")never becomes a route. Resolve HTTP methods conservatively: a statically unresolvable method resolves to"ALL"so it stays in the mutating-route vote; never silently drop it to GET. - Implement the body-first auth classification with the same five-rule precedence the three existing extractors share (
classifyHookAuth,classifyGoMiddlewareAuth,classifyRustAuth): (1) a veto segment on the name means not-auth, even over a real reject in the body; (2) a readable body with a verified reject is the only bless; (3) a fully visible non-enforcing body is not-auth, a name never rescues it; (4) an opaque body isunsurewhen the name is auth-flavored, else not-auth; (5) an unreadable or imported body behaves like (4). A name alone never blesses. - Define "verified reject" narrowly for the language: a 401 blesses alone; a 403 blesses only when the guarding condition structurally reads a credential surface; prune rejects inside nested closures (they do not run inline); allow at most a one-hop same-file helper follow with a cycle guard. Rust adds produce-position gating on top; study
security-ast-rust.tsif your language has expression-oriented returns. - Wire the extractor into the orchestrator
src/drift/security-consistency.ts: dispatch to the AST path only on a clean parse (!tree.rootNode.hasError) and decide the fallback explicitly. Python and Go route parse errors to a legacy regex path; Rust has no regex fallback and yields zero routes on a parse error. Either choice is fine; an AST path that half-runs on a broken tree is not. - Add a language noun to
HOOK_PHRASEinsecurity-consistency.tsso hedged copy reads naturally ("a before_request hook", "a middleware", "an extractor or layer"). - Optionally extend cross-file resolution in
src/drift/security-xfile-index.tsso an imported hook can be classified by its in-repo defining body. The governing rule there: resolution is path-anchored, never name-searched, and every ambiguity refuses. A resolved body still has to verifiably reject; the index adds no new bless path. - Build the fixture gate:
test/calibration/<lang>-security-fixture.tsplussecurity-<lang>.test.ts, mirroring the existing Python/Go/Rust suites. These run on everynpm test. Cover the scenario families below, each corpus in its own directory root (therepoHasAuthMachinerybaseline check is repo-global, so mixing corpora leaks auth evidence between scenarios). - Add an isolated trend row (like
security_posture_rust) totest/calibration/precision-recall.tssonpm run calibratetracks the language's precision and recall separately; per-language rows are never merged into the shared baseline row. - Unit tests for the extractor itself go in
test/unit/drift/security-ast-<lang>.test.ts.
| Family | What it pins |
|---|---|
| S0 | Recognition self-check: every fixture file yields exactly the expected route count, so a recall regression fails loudly instead of vanishing from a vote |
| S1 / S2 | Primary dominance vote: one planted deviator among an authed majority, precision and recall 1.0, through two different bless mechanisms |
| S3 | Uniform-auth-gap fallback: strip auth from every route and the fallback must fire (the ratio vote goes silent at 0%) |
| S4 | Negative control: uniformly public webhook receivers; non-vacuity asserted before zero findings |
| S5 | Uniformly-authed control: zero findings on every axis |
| S6 | Name reads as auth but the body does not enforce: flat not-auth, finding not suppressed |
| S7 | Boring-named hook whose body verifiably rejects: blesses on the body alone |
| S8 | Unresolvable body: unsure, hedged copy naming the hook |
| S9 | (Python) methods=VAR resolved through a single top-level literal assignment |
| S10 / S11 | Cross-file positive and negative: an in-repo hook body resolves and blesses; the same shape imported from outside the repo stays hedged |
The invariant that must hold, stated in every extractor's module doc: never-false-bless. The extractor may under-report auth (a recall miss becomes an over-flag, which the hedged copy softens), but it must never mark an unauthenticated route as authenticated. An unsure outcome never sets an auth lane and never enters the file-middleware union; it only records the hook name in RouteInfo.authUnsureHook, and a blessed route never carries that field.
Recipe 4: add an output format#
- Create
src/output/<fmt>.tsexporting a purerender<Fmt>Report(result: ScanResult)returning a string orBuffer. Renderers are presentation only; no analysis, no network. - Add the format literal to
ReportFormatandVALID_FORMATSinsrc/core/project-config.ts. This both validates--formatand lets.vibedrift/config.jsonset the format as a committed project default. - Add a dispatch branch in
renderToFormatinsrc/cli/commands/scan.ts, following thecsv/docxpattern: dynamic import of the renderer, a default output filename (vibedrift-report.<ext>), and respect for--output. - Update the
--formathelp text insrc/cli/index.ts, and updateREADME.md(CONTRIBUTING.mdrequires a README update for any flag or feature change). - Respect the honesty plumbing: render from the
ScanResultas given. Below-peer-floor security findings and the suppression audit are already filtered out of the drift view at one upstream source point (scoredDriftViewinsrc/drift/index.ts), so do not re-derive drift rows from raw findings; and if you render per-category scores, keep the security row consistent with a composite N/A the waysrc/output/csv.tsdoes. Machine-readable formats must keep stdout clean; thejsonpath (data on stdout, notices on stderr) is the model. - Add tests under
test/unit/output/, and consider the zero-dependency bias before adding a rendering library: the DOCX renderer hand-rolls its OOXML ZIP for exactly that reason.
House conventions#
- Commits:
feat|fix|docs(scope): description, for examplefix(drift): handle empty directories in the dominance vote. - Gates before any PR:
npm run lint,npm run typecheck,npm test,npm run build. All four must pass. - Calibration gates that must stay green: the
security-floorprecision gate innpm run calibrate(the run fails below 0.95 precision,FLOOR_PRECISION_GATEintest/calibration/precision-recall.ts); the multilang security suites (recipe 3), which run on everynpm test; andnpm run calibrate:monotonic, the pre-publish smoke test asserting the composite falls monotonically and by at least 3.0 points per 25% injected-drift step (test/calibration/run.ts). - Scoring changes: any change to scoring behavior bumps
SCORING_VERSIONinsrc/scoring/engine.ts. Cross-version score deltas then suppress themselves and users get a single one-time notice; there is nothing else to wire. New scoring heuristics also get a section indocs/algorithms.md(what, why, limitations, tests). - Code style (
CONTRIBUTING.md): strict TypeScript, ESM throughout, named exports only, async/await (no.then()chains), throw on error rather than returning error-shaped objects,@/*aliasessrc/*. - Tests: new behavior needs tests; bug fixes need a regression test.
- Docs: when your change makes a handbook chapter stale, fix the chapter in the same PR (
docs/handbook/README.md).
docs/handbook/13-extending.md, then rebuild with node scripts/build-handbook.mjsGlossary#
Every term of art used across this handbook, alphabetized. Each entry names the primary source file where the concept lives.
baseline: two related senses. (1) The peer-group norm a detector measures deviation against: the dominant pattern in a project or directory. Drift requires a baseline; no baseline, no finding. (2) The RepoDriftBaseline: a persisted per-repo snapshot (~/.vibedrift/baseline-cache/) of the dominance votes, security sub-votes, intent hints, and a MinHash function index, built as a scan side effect or lazily on first tool call, so the MCP tools answer in milliseconds instead of re-scanning (src/core/baseline.ts, BASELINE_VERSION = 3).
beacon: the anonymous telemetry POST sent after a scan to /v1/beacon/scan, carrying only aggregate fields (language, file count, lines, scan time, CLI version, deep flag, git and intent-hint booleans, finding count, score, and an authed boolean); no code, paths, tokens, or identifiers. Opt-out via vibedrift telemetry disable, VIBEDRIFT_TELEMETRY_DISABLED, or --local-only (src/telemetry/beacon.ts). A second, separate report-open beacon fires from logged-in HTML reports.
blessing: marking a route as carrying a security property, most often authentication, so it joins the dominant side of the security vote instead of being flagged. Blessing is deliberately hard to earn: in Python, Go, and Rust only a verifiably rejecting body (or a curated per-route decorator) blesses; a name alone never does (src/drift/security-ast-*.ts).
Code DNA: Layer 1.7 (src/codedna/). Local semantic analysis over extracted function bodies: semantic fingerprints for exact duplicates, the MinHash/LSH near-duplicate engine, operation sequences, pattern classification, taint analysis, and deviation heuristics.
composite score: the 0 to 100 headline produced by the scoring engine as a geometric mean of per-category health over the applicable categories. Computed per track; the drift track's composite is the Vibe Drift Score (src/scoring/engine.ts).
consistency score: per drift finding, (dominantCount / totalRelevantFiles) × 100: the share of relevant files (or routes) on the dominant side of the vote (src/drift/types.ts).
consistencyImpact: the expected gain in a finding's category score if that one finding were resolved, computed by exact recompute under the scoring model. Populated on the drift track by computeScores and used to rank the Fix Plan (src/core/types.ts, src/scoring/engine.ts).
count-based finding: a DriftFinding with countBased: true, meaning it measures a count phenomenon (duplicate pairs, phantom exports) rather than a peer ratio. The scoring engine routes it through a size-normalized density branch instead of reading its consistencyScore as a deviation rate (src/drift/types.ts).
deep scan: the optional, metered Layer 2. --deep (or an in-loop deep: true check) sends sampled function snippets, at most 30 functions of at most 60 lines each and never full files, to the hosted /v1/analyze service for embedding-based duplicate, intent, and anomaly detection with server-side LLM validation. Duplicate, intent, and anomaly results merge locally only at confidence 0.85 or higher; panel-confirmed reimplementations merge as returned (their confidence is the server panel's vote ratio). Any failure degrades to the local result (src/ml-client/).
deviating file: a file whose detected pattern differs from its vote group's dominant pattern. Carries the detectedPattern and line-level evidence, and may be reclassified as legacy by pivot detection (src/drift/types.ts).
digest: a stable 16-hex-character SHA-256 key for a finding, built from the analyzer id, file, line bucket (floor(line/3)), and a number-normalized message. Digests make scan-over-scan diffs survive small edits (src/core/history.ts).
dominance vote: the core drift mechanism: reduce each file (or route) to one pattern, count weighted votes within a peer group (project- or directory-scoped), require a minimum group size (3) and a dominant share (0.7), then flag the minority as deviators (src/drift/utils.ts).
dominant pattern: the winning pattern of a dominance vote; the convention the repo has de facto settled on for one dimension.
drift: deviation from the codebase's own dominant conventions. The product's unit of measurement, deliberately distinct from "quality": drift is always relative to a baseline the repo itself established.
drift-kind / hygiene-kind: the routing split declared in src/scoring/categories.ts. Drift-kind analyzer ids (grounded in dominance, similarity, or taint signals) feed the Vibe Drift Score; hygiene-kind ids (classic linter signals with no repo baseline) feed only the Hygiene Score. Unknown ids default to hygiene.
entropy gate: normalized Shannon entropy over a vote's pattern counts (H / log2(k)). Above 0.8 the vote emits a single "no dominant convention" finding instead of flagging deviators, because drift needs a norm to deviate from (src/drift/utils.ts).
Finding: the universal output record every analyzer and detector produces (src/core/types.ts): analyzerId (the routing key), severity, confidence, message, locations, tags, plus optional consistencyImpact, driftSignal, dupGroupSize, and renderer metadata.
fingerprint: a hash of a normalized function body (comments stripped, local variables renamed positionally, literal values preserved verbatim) used to group exact semantic duplicates. Groups are formed by a two-pass FNV-1a hash and verified with SHA-256 (src/codedna/semantic-fingerprint.ts).
hedge (unsure): the third security outcome besides authed and not-authed. An unsure hook never blesses; the route stays flagged, but the copy softens to "auth not confirmed, double check hook '<name>'", naming the hook that could not be verified (src/drift/security-consistency.ts).
Hygiene Score: the parallel 0 to 100 score computed from hygiene-kind findings only. Rendered separately and never mixed into the Vibe Drift Score (src/scoring/engine.ts).
intent hint: a team-declared convention parsed from CLAUDE.md, AGENTS.md, or .cursorrules (src/intent/parser.ts). Hints at confidence 0.6 or higher seed the dominance vote (the declared pattern's weight is boosted 1.5x), and a vote that contradicts a declaration is stamped with intent-divergence provenance.
LSH: locality-sensitive hashing. MinHash signatures are split into 16 bands of 8 rows; any pair colliding in at least one band becomes a candidate pair, which is then verified with token LCS similarity (src/codedna/minhash.ts).
managed block: the delimited region --inject-context maintains inside CLAUDE.md or another AI-rules file, fenced by <!-- vibedrift:context:start (auto-generated, do not edit by hand) --> and <!-- vibedrift:context:end --> markers and upserted idempotently in place (src/output/inject-context.ts).
MinHash: a similarity sketch: 128 seeded hash permutations over a function's token shingles produce a compact signature whose agreement rate estimates Jaccard similarity between functions (src/codedna/minhash.ts).
mutating route: a route registered for POST, PUT, PATCH, DELETE, or ALL. The auth dominance vote runs over mutating routes only (intentionally public GETs would poison the denominator), and unresolvable methods resolve to ALL precisely so they stay in this vote (src/drift/security-consistency.ts).
never-false-bless: the strongest honesty invariant in the codebase, governing the security extractors: the analysis may under-report auth (an over-flag, softened by hedged copy) but must never mark an unauthenticated route as authenticated. Enforced by body-first classification, produce-position gating, refuse-on-ambiguity cross-file resolution, and the rule that unsure never blesses (src/drift/security-ast-*.ts).
noisy-OR damage: the scoring aggregation model. Each detector group deals damage of at most 0.85, computed from severity, confidence, file importance, deviation magnitude, and sample confidence; a category's health is the product of (1 - damage) across its detector groups, so overlapping evidence saturates instead of stacking linearly (src/scoring/engine.ts).
non-shippable path: generated, fixture, mock, snapshot, test, or example paths (src/codedna/nonshippable.ts). A duplicate group is dropped only when every member is non-shippable, and the scoring engine down-weights findings in such paths.
op sequence: a function body reduced to a sequence of 22 abstract operation codes (INPUT, AUTH, VALIDATE, QUERY, and so on). LCS similarity over op sequences measures workflow-shape overlap; the data feeds drift signals and the deep-scan tease but is deliberately not surfaced as standalone findings (src/codedna/operation-sequence.ts).
peer floor: MIN_SECURITY_PEERS = 4. A security-consistency finding whose vote saw fewer than four relevant routes is re-tagged to the advisory hygiene id security_posture-advisory: it still renders, but a thin sample never dents the composite (src/scoring/engine.ts).
phantom scaffolding: exported CRUD-named handlers that are never imported and never appear in any route table; the "complete" endpoints an AI session generated that nothing ever wired up (src/drift/phantom-scaffolding.ts).
pivot: a temporal majority shift detected within a drift category: the recent dominant pattern differs from the legacy one. Deviators aligned with the old majority are reclassified as legacy (migration candidates) rather than drift (src/drift/pivot-detector.ts).
produce-position: the Rust-specific hardening of "verified reject": a 401 counts only where it is produced as a value (a return, a ? operand, a block tail, Err(...), .ok_or(...), or a match/if branch tail). A 401 mentioned in a comparison, call argument, or discarded binding is a mention, not a reject, and never blesses (src/drift/security-ast-rust.ts).
SCORING_VERSION: the scoring methodology tag (currently "v11"), persisted with every scan. When the stored version differs from the current one, score deltas are refused rather than computed, and the user sees a single one-time "scoring refined" notice instead of per-scan banners (src/scoring/engine.ts, src/core/scoring-notice.ts).
security floor: the small set of highest-precision security rules (committed secrets, cloud keys, disabled TLS verification) emitted under the distinct analyzer id security-floor. They drive a render-only "fix before shipping" badge that never changes the score, and they carry an enforced calibration gate of at least 0.95 precision (src/analyzers/security.ts, src/output/floor-badge.ts).
shingle: a run of k consecutive normalized tokens (k = 5) from a function body; the unit over which MinHash similarity is computed (src/codedna/minhash.ts).
suppression annotation: the inline // @vibedrift-public comment on a route registration (or a security.allowlist glob in .vibedrift/config.json) that removes a deliberately public route from both sides of the security vote. Every suppression emits a counted hygiene-kind audit finding, so exclusions are always visible but can never move the composite (src/drift/security-suppression.ts).
taint: Layer 1.7 dataflow analysis: request-derived sources (params, query, body) are tracked to dangerous sinks (SQL, command execution, path traversal, XSS, code injection, SSRF), sanitizer-aware, intraprocedurally plus one interprocedural hop via function summaries (src/codedna/taint-analysis.ts).
temporal weighting: 2 × e^(-ln2 × daysAgo / 90): a just-touched file's vote counts double, a 90-day-old file's counts once, and a year-old file's barely registers. Active only when git metadata is available (src/drift/utils.ts).
tools-core: the channel-neutral implementation of the in-loop tools, six including init (src/tools-core/); the @vibedrift/cli/tools barrel exports the five query/validate tools. MCP, the @vibedrift/cli/tools import, and the Agent Skill are thin adapters over it; nothing in it imports the MCP SDK.
uniform auth gap: the fallback vote for a route group that is uniformly unauthenticated, where the primary ratio vote goes silent at 0%. It fires only with a baseline reason to expect auth (auth machinery elsewhere in the repo, or a declared auth_required hint); with neither, it stays silent because the group could be an intentionally public API (src/drift/security-consistency.ts).
Vibe Drift Score: the headline 0 to 100 composite computed from drift-kind findings only: a measure of how consistent a codebase is with itself, not how good it is. The product's only sanctioned name for the number.
docs/handbook/14-glossary.md, then rebuild with node scripts/build-handbook.mjs