Skip to content

Analyzer

The Logrite Analyzer reads source code, not log data. Given a file and a set of logging rules (a template such as General Logging or a compliance framework), it finds the methods, works out which log statements the rules require, reports which already exist and which are missing, and generates the missing ones with correct placement for the file’s own logger. A repository scan is the backend walking a checkout and calling the analyzer once per source file; the backend then aggregates the answers into a scan result.

It is an internal service: customers reach it only through the backend’s scan routes (see Scan a repository below).

SettingValue
TechnologyNode.js + TypeScript + Express, tree-sitter parsers
LLMThrough the provider layer (AI_PROVIDER=neuralwatt); since 2026-10-07 every call uses the one pinned model deepseek-v4.1-flash (see AI models). The service’s / reports "backend":"Convex + neuralwatt"
Host port9000 prod · 9001 dev
Public hostanalyzer.logrite.ai · dev-analyzer.logrite.ai
RepoDeployd-io/logrite-analyzer (src/server.ts holds the route table)
Runs asA container owned by docker compose on each VM; deployed from the management console

From src/server.ts. Everything except the first group requires the internal token.

MethodPathAuthPurpose
GET/healthnoneLiveness, version, commit, parser status
GET/noneService name and a (partial) endpoint list
GET/api-docs, /api-docs.jsonnoneSwagger UI / OpenAPI spec
POST/internal/scan/evaluate, /internal/scan/agent · GET /internal/scan/agent/statusown internal authWarden / collector-agent scan evaluation
GET/metricstokenService metrics
GET/analysis-types, /languagestokenSupported analysis rules and languages
POST/rules/validate, /rules/proposetokenDry-run or propose a custom rule before it reaches a scan
GET/compliance/rule-suggestionstokenSuggested severity and log level per compliance rule
POST/syntax-checktokenParse-check a file
POST/analyze, /analyze/streamtokenAnalyse one or more files against explicit rules (the backend’s scan path)
POST/analyze/autotokenZero-config single-file analyse — see below
POST/workspace/register · GET /workspace/:workspaceIdtokenPer-scan workspace context
GET/jobs, /jobs/:jobIdtokenAnalysis jobs
POST/scan/key-findings, /log-generate/key-findings, /scan/compliance-key-findings, /log-generate/compliance-key-findingstokenKey-findings summaries before and after generation
POST/fix-code, /code-fixestokenRewrite a file to fix a build error or an issue
POST/analyze-exceptionstokenStatic unhandled-exception scan

/analyze, /analyze/stream, /analyze/auto, /fix-code, /code-fixes and /analyze-exceptions sit behind admission control (ANALYZE_MAX_INFLIGHT) so /health stays answerable under load.

  • GET /internal/scan/agent/status used to treat AGENT_SCAN_ENABLED as “scanning is on”. That variable only switches the periodic sweep; ingest-triggered scans (triggerAgentScan, via POST /internal/scan/agent) run whenever WARDEN_INTERNAL_TOKEN is set. An estate with the sweep off therefore reported SCANNER_DISABLED for logs that had in fact been examined. The response now carries paths: {periodic, ingestTriggered}, always fetches the logs bundle, and is SCANNER_DISABLED only when neither path is enabled — with the message “Neither log scan path is enabled in this environment, so these logs have not been examined. Set AGENT_SCAN_ENABLED=true (periodic scan) or WARDEN_INTERNAL_TOKEN (ingest-triggered scan) on the analyzer.” A triggered scan is never classified SCANNER_DISABLED.
  • 🔴 The trigger is sent by the ingest-shim, so the SHIM must know where the analyzer is. It posts to ${ANALYZER_API_URL}/internal/scan/agent (ai-service src/shim/envelope.ts). Prod’s ingest-shim had no ANALYZER_API_URL until 2026-10-19, so it fell back to http://localhost:9000 — its own container — and prod had never written a real collector flag for any workspace (every flag in agent-flags-303 was seeded, demo:true), while the status route reported the path as enabled. Set ANALYZER_API_URL=http://analyzer:9000 on prod’s ingest-shim (dev: :9001); within two minutes the CareVault care-management collector got its first real hipaa_log_leak_phi flag. Since ai-service d7cabe54 (2026-10-21, CX-21) there is no default: with the variable unset the shim logs once at startup [shim] collector scan trigger disabled: ANALYZER_API_URL unset and sends no trigger, and a failed trigger is logged at most once a minute instead of swallowed. Prod’s analyzer also runs AGENT_SCAN_JUDGE_MAX_LINES=0, as dev does: framework and pattern rules flag collector lines, the plain-English judge does not (no per-ship LLM cost) — and the status route now says so (JUDGE_DISABLED, below).
  • POST /internal/scan/evaluate (the policy dry runs, /api/v1/policies/{id}/test and /effective/test) bounds the plain-English judge at EVALUATE_JUDGE_TIMEOUT_MS = 12000. The backend gives the call 20 s (WardenLogsChannel), and with no bound the judge’s two sequential tiers could outlast it, so the user saw a timeout for a dry run that was still working. On expiry the result carries judge: 'timeout': the plain-English layer is skipped, framework and pattern findings are kept. Since 2026-10-23 collector scans pass the same 12 s bound (LINE_JUDGE_TIMEOUT_MS), and on expiry the timer cancels the model call rather than only stopping the wait, so neither path leaves a request (or its flex-lane retry) running behind it. Since 2026-10-21 the body accepts feature (the backend sends it); for logs with AGENT_SCAN_JUDGE_MAX_LINES=0 the judge is skipped and the result carries judge: 'disabled', so a dry run no longer runs rules the real collector scan does not (AN-4). Otherwise judge is 'ran'.
  • POST /rules/validate (custom logging-rule dry run) parses the fixture in the project’s language when the backend sends a top-level language (the language of the project’s latest scan, backend 42debe8a) and a fixture exists for it; absent or unknown means Java, as before (analyzer 970b8027, AN-12). POST /rules/propose gives the model 60 s (PROPOSE_TIMEOUT_MS); past it the route answers 503 model_timeout “The language model did not answer within 60 seconds. Retry in a minute.” with retryAfter: 60 (analyzer 78b3e672, AN-11). Since 2026-10-23 the 60 s timer also cancels the model call; before, the late reply was only dropped, and the request kept one of the account’s provider slots until the client’s 300 s limit.
Terminal window
curl -s https://analyzer.logrite.ai/health
{"status":"healthy","timestamp":"2026-10-01T07:35:28.730Z","version":"2.0.0-unified",
"parsers":{"treeSitter":{"ok":true}},
"commit":"c10063df747789bee4d9cb17304d8411e0d3e3ff","branch":"main",
"builtAt":"2026-09-29T09:15:07.871Z"}

commit and builtAt are the quickest way to tell which build an estate is running.

Every route answers an omitted mandatory field through src/api/validation/requiredFields.ts, the analyzer’s half of a contract the Java backend implements identically — see API Validation. One sentence, the wire key, and every absent field at once:

POST /code-fixes {}
400 {
"error": "userPrompt, content, language and fileName are mandatory",
"message": "userPrompt, content, language and fileName are mandatory",
"errorCode": "missing_fields",
"missingFields": ["userPrompt", "content", "language", "fileName"],
"details": {
"userPrompt": "userPrompt is mandatory — describe the issue to fix",
"content": "content is mandatory — the source to fix",
"language": "language is mandatory — e.g. java, javascript, python",
"fileName": "fileName is mandatory"
}
}

That request used to cost four round trips: each check returned on the first miss, so a caller learned about userPrompt, fixed it, resent, learned about content, and so on. /analyze and the four key-findings routes had the same shape.

npm run test:requiredfields holds both halves — the wording fixtures, shared with the backend’s RequiredFieldsWordingTest, and a sweep over every file in src/api/routes that fails on the first route to invent its own absence message.

⚠️ POST /analyze/stream with a JSON body is validated before its 200 is committed. It used to answer HTTP 200 with the error as the response’s only line. The NDJSON branch still reports in band: the body is the input stream, so whether a header line exists is only knowable after reading it.

This is the customer path. It needs an API key (or a signed-in session); the backend clones the repository with the workspace’s GitHub App installation and calls the analyzer for you.

  1. Connect GitHub — in the app, Settings → Workspace Settings → Source control → Connect GitHub, then Add repositories, which installs the Logrite GitHub App and lets you choose repositories.

  2. Create a project for the repository with Add new project (Generator or Compliance page) and pick its logging template. Its id is in GET /api/v1/project/workspace/{workspaceId}.

  3. Start the scan:

    Terminal window
    curl -s -X POST https://leapfrogapi.deployd.io/api/v1/scan/initiate \
    -H "Authorization: Bearer $LOGRITE_API_KEY" -H "Content-Type: application/json" \
    -d '{"projectId": 4252, "branch": "main", "template": "General Logging"}'

    The answer comes back at once with status: "RUNNING", afterScanId (the newest scan before this one) and pollUrl. template defaults to GENERAL. A prNumber is accepted but not yet supported in real mode (pr_number_not_yet_supported). The scan records the checkout’s real 7-character commit, not the literal HEAD. A project you cannot see is a 403, one that does not exist a 404 — never a 500.

  4. Poll GET /api/v1/scan-runs/latest?projectId=4252 until state is COMPLETED (or FAILED, with failureReason) and scanResultId is newer than afterScanId. The demo repository (78 files) took about 15 seconds on dev.

  5. Read the summary: GET /api/v1/scan-results/{scanResultId}/summary.

The full request/response walk-through, run on dev with a real key, is in the Quickstart.

To turn a finished scan into a pull request that adds the recommended logs, see Generate logs (open a PR).

GET /api/v1/scan-results/{id}/summary carries four severity buckets — critical, high, medium, low — each as a pair:

FieldMeaning
<severity>TotalLog statements the template expects at this severity that are missing — the outstanding count
<severity>PercentThe share of the expected statements at this severity that already exist (coverage, 0–100)

They are two views of one invariant. For each bucket the analyzer counts actual (already logged) and total (outstanding), so expected = actual + total, and percent = actual / expected. When generated logs are placed, ScanCoverageRecorder moves each placement from outstanding to actual — Total falls, Percent rises, expected does not change. So highTotal: 133, highPercent: 0.0 reads “133 HIGH-level logs are missing and none of the expected HIGH logging exists yet”, not “133 high-severity defects”.

Also on the summary: startLogCoveragePercent (overall coverage when the scan ran), endLogCoveragePercent (after generation, null until logs are generated), language, numberOfFilesScanned, keyFindings and appliedTemplates. runDetails.analyzerDrops and skippedFiles explain what was not counted and why.

There is no customer route that runs the analyzer on an arbitrary file. Today the closest is the IDE route on the backend:

Terminal window
curl -s -X POST https://leapfrogapi.deployd.io/api/v1/ide/check \
-H "Authorization: Bearer $LOGRITE_API_KEY" -H "Content-Type: application/json" \
-d '{"projectName": "payments-api", "fileName": "PaymentService.java",
"code": "<the file contents>"}'
# 200 {"recommendedCode": "<the file with recommended logs inserted>", "skipped": false}
FieldRequiredNotes
projectNameyes (or projectId)A project of yours that has already been scanned — its latest scan on any branch supplies the template and the checkout
codeyesThe file’s contents
fileNamenoA bare file name (PaymentService.java); selects the language. A path is a 400: fileName must be a bare file name such as "OrderService.java" — no directories, no "..", no absolute path
projectUrlnoNarrows projectName when several projects match

It answers with the file rewritten, not with a findings list. When the file was deliberately not instrumented the response says so (additive fields, 2026-10-01): skipped: true, recommendedCode equal to the input, and skipReason / skipMessage:

skipReasonWhy
emptythe file is empty
unsupported-typethe file type holds no method bodies to instrument
dtodata/DTO file — fields and accessors only
entry-pointthe file contains main
{"recommendedCode": "<the file unchanged>", "skipped": true,
"skipReason": "entry-point", "skipMessage": "entry-point file (contains main) — not instrumented"}

skipReason and skipMessage are absent when skipped is false. A logger’s imports are inserted at the file’s import point, never inside the class. Missing code is a 400 ({"code":"code is required"}); a project with no prior scan is a 404 (No prior scan found for project '…'. Run a scan on this project first.).

The project is resolved among the caller’s own projects (a key’s workspace only), its latest scan on any branch supplies the template and checkout, and fileName must be a bare file name — a path (/, \\, ..) is a 400. Full rules: Backend → IDE check.

All of these require X-Analyzer-Token; the backend is the caller.

The fix prompt branches on category (bug default · performance · compliance) and accepts two optional cross-cutting modifiers used by the backend’s autonomous Fix-it:

FieldEffect
compilerErrorThe exact build error to fix — drives the backend build-verify repair loop (fix the smallest thing that makes the file compile).
mode: "preventive"Proactively ADD missing exception handling — wrap risky ops in try-catch, add null/bounds/divide-by-zero checks, and auth/login guards — without changing success-path behavior.

Returns the full rewritten file (markdown fences stripped); temperature 0.1.

POST /analyze-exceptions — static exception scan

Section titled “POST /analyze-exceptions — static exception scan”

Input { content, fileName, language }. The LLM lists methods that can throw an UNHANDLED runtime exception or lack necessary handling (NPE, divide-by-zero, IndexOutOfBounds, ClassCast, NumberFormat, unchecked I/O/DB calls, and auth/login paths that proceed without verifying the user/session). Returns { findings: [{ className, methodName, exceptionType, line, description, severity }] } ([] when none). Consumed by the backend’s /api/v1/code-analysis and the autonomous hardening pass.

POST /analyze/auto — zero-config single-file analyse (internal)

Section titled “POST /analyze/auto — zero-config single-file analyse (internal)”

Requires X-Analyzer-Token like every other analysis route; it is not customer-callable. A stateless wrapper around /analyze for an internal caller that does not want to supply a language, rules or template metadata.

{ "filename": "PaymentService.java", "content": "<source code>", "template": "GENERAL" }
FieldRequiredNotes
filenameyesSelects the language from its extension. The accepted extensions are generated from the language catalogue (extensionLanguageMap()); an unknown one is a 400 that lists them all
contentyesRaw source text
templatenoGENERAL (default) or any framework in the compliance catalogue — 29 keys on 2026-10-01, e.g. SOC2, HIPAA, ISO27001, PCI, M2614, EUAIACT. Spelling is normalised (ISO 27001 = ISO-27001 = iso27001); an unknown one is a 400 naming the real list

GENERAL runs method_entry_logging, method_exit_logging, exception_handling, method_variable_tracking and external_service_calls_response_times; a framework runs its single <framework>_compliance rule. The response is the /analyze single-file shape ({ filename, results, methods }) plus auto: { template, language }. No DB write, no OpenSearch write; a file FileFilter excludes (Entity/DTO/Util) returns an empty result with skipped.reason.

React/JSX files are the trickiest injection target: a log statement is only valid in statement position, never inside JSX markup. The analyzer (ReactRecommendationEngine) must therefore not place a logger.debug(...) line:

  • Inside a JSX expression container — e.g. between a ternary’s condition and its branches:

    {mode === "compliance"
    logger.debug(`...`); // ❌ hard syntax error — a statement cannot live here
    ? "Loading compliance projects…"
    : "Loading projects…"}

    This is the failure behind Logrite-UI PR #370 (Generator.jsx, 2026-05-31): the log landed between {mode === "compliance" and its ?/: branches, which the bundler rejects with Unexpected token, expected "}".

  • Inside a JSX opening tag, between attributes, or in the middle of a multi-line element.

Why these slip to the build step: such a line usually keeps braces balanced, so the backend’s .js-only JsSyntaxGate (which doesn’t parse .jsx) cannot catch it — it only surfaces at the “Testing Changes” build. Correct behavior is to anchor JSX log placement to real statement positions (function-body lines, before a return, inside {} blocks) and never to a line that sits inside an expression/tag. Related prior JSX-placement fixes: generics-mistaken-for-JSX and var-type arrows (analyzer dev 8783277).

Exit logs inside a nested object literal (analyzer dev b8a4152, 2026-06-02)

Section titled “Exit logs inside a nested object literal (analyzer dev b8a4152, 2026-06-02)”

A second statement-position failure, same family. For a void function (no return) whose last top-level statement is a call carrying a nested object literal, the exit-log fallback (jsExitLineFinder.safeFallback) used to compute endLine-1 then snapAwayFromClosingBrace. That stepped back over the }))/}) closer and dropped the <<< Exiting … log between an object’s last property and its }:

setReviewTexts(prevState => ({
...prevState,
[index]: newText
logger.debug(`<<< Exiting …`); // ❌ Expected "}" but found "logger"
}))

Real failures: Hotel-Management BookingPage.jsx (setReviewTexts(p => ({...}))) and Reserve.jsx (saveBooking({ notes: { address } })). These only surfaced once the module-aware build made monorepo client/ builds actually run — vite then rejected them. The .js-only JsSyntaxGate can’t catch .jsx, so there was no earlier net.

Fix (analyzer): a Tree-sitter helper findLastStatementEndLine returns the end line of the last top-level statement of the function body — a guaranteed statement boundary — and safeFallback anchors the void exit log there (no snapAwayFromClosingBrace on the AST result). The log now lands after the statement’s closer, before the function’s }. Covered by tests/verify-exit-placement.ts (unit) + tests/verify-pipeline-e2e.ts (full analyzeCode pipeline) against tests/fixtures/exit-placement-cases.ts.

Fix (backend, Logrite-AI main ce2750f): the analyzer recommends the right line, but placement is finalized by the backend’s MethodLastLineIndexFinder (used by FileUtil to find a method’s closing brace). It recognized only Java name(...) and JS const/let/var name = declarations — a function defined as an object property / class field (name: function(){}, name: (…) => {}) matched neither and returned -1, so FileUtil dropped the exit log into the following sibling object member (Razorpay handler: function(){} → log spliced into the next notes: {} → Expected "}"). A 3rd additive start-pattern was added (only runs when the two existing patterns miss, so Java/JS/JSX/Node detection is byte-identical — proven by MethodLastLineIndexFinderDiffTest). FileUtilObjectPropertyExitTest drives the real addMultipleLinesToFile and asserts the handler exit lands inside the handler body.

C# (.NET) injection — language support & placement rules (2026-06-29)

Section titled “C# (.NET) injection — language support & placement rules (2026-06-29)”

C# is a first-class scan target (analyzer dev 231ccb2, backend Logrite-AI main 39d78eb). Verified end-to-end against Deployd-io/anystore_CSharp (WinForms / .NET Framework 4.5.2) — Build succeeded. 0 Error(s) after injecting 1125 logs across 19 files — and a net8.0 SDK sanity project covering all 7 rule types.

C# logs are self-contained: Console.WriteLine("…{0}…", arg) composite format (not $"…" interpolation — that requires C# 11) plus System.Diagnostics.Stopwatch for timing. No logger field or using directive is required, so an injected log compiles in any file.

Analyzer (logrite-analyzer dev):

  • Language aliasing — the backend’s LanguageDetector emits "C#"; AnalysisEngine normalizes c#/cs/c-sharp/dotnet → csharp so the C# parser/generator path is taken (without this, C# fell through to the generic parser and extracted 0 methods — the “scan runs through C# files but finds nothing” symptom).
  • Method detection (CodeAnalyzer.parseCSharpMethods) is anchored at line start behind an access/modifier gate with a real Name(params) body opener. The previous unanchored regex matched access keywords as substrings of ordinary code (publicwidthoverrides.ContainsKey(…) → bogus method ContainsKey), mislocating logs.
  • out parameters are dropped from the logged set (extractParameters). An out param is not definitely assigned at entry, so logging it before assignment is CS0269. ref/in are kept.
  • Auto-generated files are skipped (isAutoGeneratedFile): *.Designer.cs, AssemblyInfo.cs, and any file with the // <auto-generated> header. WinForms designers carry no using System;, so an injected bare Console.WriteLine would be CS0103; they are also regenerated by their tool. Marker-gated — hand-written files in any language are never skipped.
  • External-call timing logs only interpolate a service-URL variable that is genuinely in scope for the method (a parameter or a local declared in the body); when the URL is an inline literal the placeholder is omitted entirely (was a file-wide name match that pulled an unrelated method’s baseUrl → CS0103).
  • Object/collection initializers (new Type { Prop = val, … }) are skipped by the variable trackers — their Prop = val lines are property assignments, not locals (tracking them logged out-of-scope names like Key/InputStream → CS0103).

Backend (Logrite-AI main) — .cs post-injection guards in FileUtil (the Java path is byte-for-byte unchanged; guards run only for .cs):

  • relocateClassBodyLogsIntoMethodCs — scope-kind-aware class-body relocation that accounts for C#‘s namespace wrapper (type-member level is brace depth 2+, not 1).
  • ensureTimerStartDeclaredCs — injects var __logStart = System.Diagnostics.Stopwatch.StartNew(); as a method’s first statement when a timing log references __logStart.ElapsedMilliseconds without it (the C# counterpart of ensureTimerStartDeclared, scope-kind-aware).
  • relocateVarTrackBeforeFirstAssignmentCs — moves a var-track log that landed before the variable’s first assignment to after it (CS0165 unassigned local).
  • fixInjectedLogsBeforeElseCs — a log stranded before else/else if breaks the if/else pairing; it is kept inside its branch so the logged variable stays assigned.
  • hoistLogsBetweenOpenerAndBlock / wrapBraceFreeConditionalBodies — Allman-brace and brace-free conditional bodies; the brace-wrap terminator detection now ignores trailing // comments and keeps else logs inside the new braces (also benefits Java).
  • Overload / delegate disambiguation — extractMethodCallSignature now parses the entry/exit signature shape >>> Entering Name(a={0},b={1}) (placeholders inside the parens) in addition to the audit shape Name(a,b)={},{}, so an overloaded Log(Exception ex) entry/exit log no longer lands in the sibling Log(category,msg). MethodLastLineIndexFinder no longer treats new Name(…) constructions (e.g. printDoc.PrintPage += new PrintPageEventHandler(PrintPageEventHandler)) as declarations of Name.

Build-tool detection (BuildUtil): dotnet projects are detected by file extension (*.csproj, *.sln, *.fsproj, *.vbproj) rather than literal filenames — a real project is AnyStore.csproj, not .csproj. This fixes the original Unsupported build tool: unknown scan failure. See backend.

Coverage: FileUtilCSharpGuardTest (overload entry/exit, delegate-construction-not-a-decl, var-track-before-assignment, timer-start injection, log-before-else) and BuildUtilDiscoveryTest (extension-based detection; other toolchains unaffected) — part of the 133-test util suite.

.NET language family expansion — VB.NET, F#, Razor, Web Forms (2026-06-30)

Section titled “.NET language family expansion — VB.NET, F#, Razor, Web Forms (2026-06-30)”

Scan support was extended beyond C# to the rest of the .NET family (analyzer dev 869784f, backend Logrite-AI main 86e11b7 / a1e3666). Language is resolved per file by extension, so a single mixed project (e.g. Web Forms with .aspx, .aspx.cs, .cs, plus a .vb or .fs module) has each file routed to its own engine at injection time — CodeFixService/SourceFileClassifier call LanguageDetector.detectLanguage(file) on every file, not once per project.

Per-extension routing (LanguageDetector):

Extension(s)LanguageEngine / coverage
.cs, .aspx.cs, .ascx.cs, .cshtml.cs (code-behind)C# → csharpCSharpCodeGenerator — first-class, see the C# section above
.vb, .aspx.vb, .vbhtmlvbVBNetCodeGenerator
.fs, .fsx, .fsifsharpFSharpCodeGenerator
.aspx, .ascx, .master, .ashx, .asax, .cshtml, .razor (markup)C# (or VB via <%@ Page Language="…" %>)markup-aware — embedded code regions (@code/<script runat="server">/<% %>) extracted and line-remapped (see below)

Analyzer (logrite-analyzer dev):

  • CodeGeneratorFactory routes vb/vbnet/vb.net/visualbasic → VBNetCodeGenerator and fsharp/f# → FSharpCodeGenerator; unknown languages still default to the Java generator (backward compatible, additive — the existing five languages are byte-identical).
  • VB/F# logs are newline-terminated with named placeholders and no trailing ; (e.g. VB _logger.LogDebug(">>> Entering Add(a={a},b={b})", a, b), F# logger.LogDebug(…)), using logger fields _logger / logger.
  • CodeAnalyzer gained parseVBMethods / parseFSharpMethods (with findVBMethodEnd / findFSharpMethodEnd bounds — Sub/Function…End Sub/End Function; F# let/member) and a language-aware extractParameters: VB name As Type → name; F# curried/typed (name: string) → name (a typed-paren param was previously whitespace-split, leaking the type as a fake parameter — fixed and covered by tests/dotnet-engines.ts).

Backend (Logrite-AI main):

  • LanguageDetector maps the extensions above; the syntax-completeness gate treats VB/F# lines as newline-terminated (VB _ line-continuation honored) so valid statements are not mistaken for “needs continuation”.
  • BuildUtil gathers .vb/.fs/.fsx/.cshtml/.razor/.vbhtml/.aspx/.ascx/.master/.ashx/.asax, and the C# root-level gather fix (a1e3666) adds depth-0 *.cs globs so ASP.NET Web Forms Web Site projects (no .csproj, root-level code files) are no longer reported as “No source files found”.
  • FileUtil brace transforms run only for .java/.cs. VB/F#/markup are non-brace languages, so they receive analyzer line-number insertions with no brace mangling — which is why the C#-specific post-injection guards do not need VB/F# counterparts.

Self-contained logs: VB.NET and F# emit self-contained output like C# — VB uses Console.WriteLine / Console.Error.WriteLine (positional composite format) and F# uses native printfn / eprintfn (%A/%s/%d), both with System.Diagnostics.Stopwatch timing. No _logger/ILogger field or Imports/open is required, so an injected log compiles even on projects that declare no logger (VBNetCodeGenerator, FSharpCodeGenerator).

Markup-aware extraction: .cshtml/.razor/.aspx/… are parsed by markupCodeExtractor.ts → CodeAnalyzer.parseMarkupMethods, which pulls the embedded code regions (Razor @code {…} / @functions {…} / @{…}; Web Forms <script runat="server"> and <% %>, excluding <%@/<%=/<%:/<%#/<%--), parses each with the C#/VB parser, and remaps line numbers back to the original file so injected logs land in server-code, never on a raw HTML line. .vbhtml and a Web Forms <%@ Page Language="VB" %> directive route to VB. Code-behind (.aspx.cs/.aspx.vb) is covered on its own extension.

F# indentation: injected .fs lines use the anchor’s exact leading spaces (F# forbids TAB indentation, FS1161, and is offside-sensitive) via the backend FileUtil.getIndent.

Compliance leak scanning across .NET logging stacks (2026-07-19): when scanning existing customer code for PII/PHI/secret leaks, the analyzer must first recognize a line as a logging call (CodeAnalyzer.findLoggingStatements → LogLeakDetector.detectAll). Recognition now covers the real .NET/ASP.NET sinks — previously only SLF4J-style logger.x(/console.x( matched, so leaks in ASP.NET Core logs were invisible:

StackRecognized call shapes
Microsoft.Extensions.Logging (default ASP.NET Core)_logger.LogInformation(, LogWarning, LogError, LogDebug, LogTrace, LogCritical; structured _logger.Log(LogLevel.Information, …)
Serilogstatic Log.Information/.Warning/.Error/.Debug/.Verbose/.Fatal(, instance _logger.Information(
NLog / log4net_log.Info/.Warn/.Error/.Debug/.Fatal( and *Format variants (InfoFormat, ErrorFormat)
C# / VB console + diagnosticsConsole.WriteLine, Console.Error.WriteLine, Debug.WriteLine, Trace.WriteLine
F#printfn, eprintfn, printf, eprintf (sprintf excluded — it builds a string, does not emit, so treating it as a log would wrongly suppress instrumentation)

Whole-object logging (hipaa_log_leak_phi_object) matching (leakPatterns.ts matchPattern, mirrored byte-identical into warden/leakPatterns.ts) now also recognizes C#/VB interpolated holes $"…{patient}…" and F# curried print args printfn "%A" patient, alongside the existing Java + patient concatenation and JS ${patient} template forms. Brace-free VB (Dim) / F# (let) local variables are now extracted by ComplianceDetector.extractAllMethodVariables (they previously yielded zero locals), and F# let/member bindings are no longer misclassified as constructors by ComplianceDetector.isConstructor. Covered by tests/dotnet-compliance.test.ts (22 cases). This is the compliance/scan flow; the generation flow (emitting a project’s own ILogger/Serilog logger instead of self-contained Console.WriteLine) is tracked separately.

.NET DB timing dropped in multi-rule scans — two root causes fixed (2026-07-19): in a multi-rule scan (the real case — a scan always requests several analysis types), database_calls_response_times was silently dropped for every .NET language (C#/VB/F#).

Root cause A — the DB-file pre-filter (server.ts isDatabaseRelatedFile). The /analyze handler filters database_* analysis types out of any file it does not classify as a “database file” when combined with a non-DB rule. That classifier only matched *.java names + Java/JPA content (@Repository, JpaRepository, EntityManager, repository.…) — so a C# EF file (_context.Users.FindAsync) matched nothing, was labelled “general”, and had its DB-timing rule stripped before analysis (Java kept it via the repository. substring). The classifier now also recognizes .NET EF Core/Dapper/ADO.NET (DbContext, DbSet<>, _context., .FindAsync/.ToListAsync/.SaveChangesAsync, SqlCommand…) and Python/JS ORM idioms (.objects.filter, session.query, cursor.execute), plus .cs/.vb/.fs/.py/ .js/.ts repo/DAO/context/service file names — pure widening (a non-DB file is never mis-added; a false positive just runs a DB rule that produces nothing).

Root cause B — the line coordinator (AnalysisEngine.deduplicateAcrossAllMethods). AnalysisEngine.deduplicateAcross- AllMethods keeps a timing-START on the method-entry line and a timing-RESULT on the exit line (both must survive; else the Stopwatch.StartNew() start lands after the duration line that uses it and the pair is discarded). Its pair-recognition covered only external_service_ calls_response_times/database_query_timing with a JS/Java timer (const _start/long start) — not database_calls_response_times nor the .NET Stopwatch.StartNew() timer. So the DB timer-start collided with the entry log, was forward-bumped past its own duration, and the whole pair was dropped. The helpers now include database_calls_response_times and the .NET Stopwatch.StartNew() timer. The DB keep-skip is scoped to methods whose SOLE timing type is DB — a method with BOTH a DB and an external call produces 4 timing logs + entry that cascade-collide near method-end, where keeping the DB start would instead drop the external “took” result; such methods keep their prior placement path (so Java’s db+external behaviour is unchanged). C#/VB/F# retain correctly-ordered DB timing in the common combined scan. Covered by tests/dotnet-timing-coordination.test.ts (7 cases, incl. a Java db+external no-regression guard). Known remaining limitation: a single method that does BOTH a DB query AND an external call still places only one of the two timing pairs cleanly (pre-existing; affects Java equally) — the deeper database+external co-placement is tracked separately. Note: the sibling Java placement-safety passes (AnalysisEngine.ts steps 1.7–1.87) remain Java-gated; .NET relies on the self-contained/structured generators + the dotnet build gate, which is why this coordinator-level fix is sufficient.

Per-file routing: the scan pipeline detects language per file (LanguageDetector/processSourceFiles), so a mixed .NET solution analyses each file with the correct parser; detectDominantLanguage picks the project-level label.

Safety net: every .NET scan is validated by a real dotnet build; malformed injection → build_fail → no PR is opened, which backs the remaining conservative edges (methods without an access modifier inside @code, deeply interleaved Web Forms expressions, VB Razor @Code…End Code).

Coverage: tests/dotnet-engines.ts (npm run test:dotnet) — factory routing, VB + F# generator output, VB/F# method parsing and parameter extraction, plus C# regression; self-contained (no running service, no dotnet toolchain required).

Code layout — per-language modules (2026-07-23)

Section titled “Code layout — per-language modules (2026-07-23)”

src/core/languages/ is organized as one folder per language surface (pure structural refactor, dev 7038e53 — files git mv’d verbatim, imports rewritten, behavior verified identical via the full suites + a 10-fixture /analyze/auto golden-file byte-diff):

FolderCoversFiles
shared/cross-languageCodeGeneratorInterface, CodeGeneratorFactory, LoggerDetector, LoggerConfig, languageDetection
javascript/JS + JSXJavaScriptCodeGenerator, jsTranslator, jsExitLineFinder (tree-sitter exit placement)
typescript/TS + TSXTypeScriptCodeGenerator
java/JavaJavaCodeGenerator
python/PythonPythonCodeGenerator, pythonTranslator
csharp/C#CSharpCodeGenerator, csharpTranslator
vbnet/VB.NETVBNetCodeGenerator, vbTranslator
fsharp/F#FSharpCodeGenerator, fsharpTranslator
dotnet/.NET family sharedDotNetLoggerDetector, dotnetFramework (app-surface classifier), markupCodeExtractor (Razor/XAML/Web Forms)

Conventions (full detail in src/core/languages/README.md):

  • Runtime wiring imports concrete files; the per-folder index.ts barrels are readability-only, so the module-init graph matches the pre-refactor layout.
  • The canonical content-based language sniff is shared/languageDetection.ts (detectLanguageFromContent, extracted verbatim from CodeAnalyzer, which now delegates). codeParser.ts still holds an older drifted private detectLanguage — intentionally not unified (different behavior/call sites).
  • Phase 2 (dev 25e0e13): the engines’ zero-this. language helpers were extracted verbatim into the language folders (30 methods, ~2,400 lines) — parsing helpers to shared/codeAnalysisHelpers, JS post-processing to javascript/jsPostProcess, per-language recommendation converters to each folder’s convertRecommendations, Python DB recommendation logic to python/pythonRecommendation, and more (full table in src/core/languages/README.md). Each engine keeps same-signature delegates, so call sites and behavior are unchanged (verified: suites at baseline + 10-fixture /analyze/auto golden byte-diff). Engine sizes: CodeAnalyzer 2446→1687, AnalysisEngine 5365→3963, RecommendationEngine 4029→3774.
  • this.-coupled orchestrators (analyzeCode, _coreDispatch, jsAnchor*) deliberately remain in the engines; extracting them requires untangling instance state first, with the same regression protocol (build + test:langcov + test:dotnet + test:warden + golden-file diff).

Log levels — every template follows the rule’s level

Section titled “Log levels — every template follows the rule’s level”

Default levels (2026-10-01). The default general template now asks for method entry, method exit and variable changes at DEBUG, database and external-service calls at INFO, and exceptions at ERROR — it used to put entry and variable changes at ERROR and exit at WARN. The backend’s CoreRuleLevelBackfill moves the catalogue rows still at the old level; saved project templates keep theirs. See Backend → Default general levels.

A rule’s analysis_rules[].logging_level decides the method of every statement the rule produces: entry, exit, exception, variable tracking, database and external-call timing, compliance and library-rule templates. Before this, several templates hard-coded their level. Database timing was always INFO in Java, C#, the brace languages, VB and F#, so a BankJava rule configuration set to WARN still got log.info("… query executed in {} ms", …). Variable-tracking escalations use escalatedVariableLevel / atLeastLevel (src/core/recommendations/variableLevel.ts), so an escalation can raise a level and never lowers an ERROR rule to WARN or INFO. That is now the only place a saved level is exceeded — see the next subsection.

Every renderer reads one table, LEVEL_METHODS through levelMethod(api, level) in src/core/languages/shared/logLevelName.ts. Each row follows one rule. Emit the logger’s own method for the level. Where the logger has no method at that severity, emit the nearest one it does have. Never emit an invented name: that is a compile error (javac, rustc, kotlinc) or a runtime NoMethodError that no syntax gate can see (Ruby). TRACE is the only level most APIs lack:

LoggerA TRACE rule renders
SLF4J, Log4j2, Log4j 1.x, commons-logging, JBoss Logging (Lombok @Slf4j, @Log4j2, @CommonsLog)trace
java.util.logging (Lombok @Log)finest, or log(java.util.logging.Level.FINEST, …, e) with a throwable
java.lang.System.Loggerlog(System.Logger.Level.TRACE, …)
FloggeratFinest().log(…)
Go logrusTracef
Go zerolog, global log or a package-level instanceTrace()
Go zap (zap.S())Debugf — the SugaredLogger has no trace method
Ruby Logger and Rails.logger.debug — Logger has no trace, and .trace passed ruby -c, then raised NoMethodError on the first call

Other rows with no trace method also render TRACE as their debug method: JavaScript console (console.trace prints a stack, not a level), Python logging, log4net (DebugFormat), PSR-3, pub logger, Zig std.log and Elixir Logger.

The ratchet. tests/template-level-ratchet.test.ts (npm run test:levelratchet) scans every file under src/ for the shape of a hard-coded level:

  • a literal log call ("logger.debug(…");
  • a literal [LEVEL] tag;
  • a literal level: / log_level: / lifecycleLevel =;
  • a literal level passed to a renderer;
  • any byLevel( call.

The allowlist counts hits exactly per file and kind. One more hit is a new hard-code, and one fewer makes the entry stale, so the list can only shrink. Test expectation tables (tests/lib/levelExpectations.ts) are written by hand and never import LEVEL_METHODS, because a copy of the table would always agree with itself.

The saved level is the level emitted (2026-09-17)

Section titled “The saved level is the level emitted (2026-09-17)”

A compliance template used to treat the request as a floor: the rule could raise it. An incident obligation held ERROR, so a rule saved at WARN emitted log.error, and the UI showed the saved level beside a statement that disagreed with it. Product decision: the level saved on the rule wins. atLeastLower(FLOOR) became requestedLevelLower(DEFAULT) at all 22 compliance call sites — the old floor survives only as the default for a caller that installs no level at all, so a direct test or fixture renders exactly what it always did.

rulebeforeafter
soc2_authorizationwarn at TRACE, DEBUG, INFO and WARNthe level asked for
soc2_database_integrityinfo at TRACE, DEBUG and INFOthe level asked for
soc2_incident_responseerror at every levelthe level asked for

Statement counts are identical in every cell: the change moves a level, it never adds or drops a log.

TRACE now reaches a compliance rule. It could not before, and the cause was a second alias table — KNOWN in logLevelPolicy.ts mapped TRACE → DEBUG while logLevelName.ALIASES kept all five. One table now, two accessors (toLogLevelName for “level or fallback”, tryLogLevelName for “recognised at all?”, which is the distinction setRequestedLogLevel needs). A third four-entry table in jsLogLevel.ts would have indexed undefined for TRACE and emitted .undefined(; it is gone too. Where a logger has no trace method the renderer falls back to the nearest real one (Python, Ruby, Elixir, Zig, log4net, PSR-3, zap and JS console all render debug; console.trace prints a stack trace, not a TRACE line), per the table above — never an invented name.

An unrecognised level can no longer reach the renderer. soc2Access.ts (the identity class, SOC 2 CC6.1) interpolated the request verbatim, so logging_level: "CRITICAL" rendered logger.critical(, "WARNING" rendered logger.warning( — neither exists on org.slf4j.Logger — and an empty string rendered logger.(, which is not parseable Java. It was the one site of 22 without normalisation; aliases now resolve (CRITICAL/SEVERE/FATAL → error, WARNING → warn) and anything unrecognised falls to the rule’s own default.

Two suites guard this, both in the test:languages chain (88 steps → 90): test:levelobeyed asserts the exact method for 23 rule types and 6 aggregates across all five levels, 10 logger APIs, 13 aliases and 7 kinds of garbage input, and that the statement count is unchanged at every level — 91 cases, of which 65 fail against the pre-fix engine. test:fwlabel sweeps every coverage rule and asserts the audit tag names the rule’s own framework. The older compliance-level-floor test was replaced: its DEBUG case asserted rank >= rank.debug, true of every possible sink, so a missing level was structurally unobservable.

Logger detection — the call shape comes from the file’s own logger

Section titled “Logger detection — the call shape comes from the file’s own logger”

The level picks a method name, and the logger the file actually declares picks the call shape and the receiver.

JVM. detectJvmLogger (shared/jvmLoggerApi.ts) reads the file with string and comment interiors masked. It checks three things, strongest first:

  1. A logging annotation, proven to be Lombok or Groovy by an import or a fully-qualified name. The field is log. A home-grown @Log(title = …) audit annotation is not java.util.logging.
  2. A logger field, by its initializer: LoggerFactory.getLogger, LogFactory.getLog, System.getLogger, FluentLogger.forEnclosingClass and similar. Logger.getLogger exists in JUL, Log4j 1.x and JBoss Logging, so the import or qualified name decides, and an unresolvable one proves nothing.
  3. Anything else, meaning an import alone or nothing at all, is SLF4J. That is what the generator emitted before detection existed.
Detected APICall shape (languages/java/jvmLogRenderers.ts)
SLF4J, Log4j2log.info("a={}", a) — unchanged
java.util.loggingLOG.fine("a=" + a); with a throwable LOG.log(java.util.logging.Level.SEVERE, "a=" + a, e)
Log4j 1.x, commons-logging, JBosslog.debug("a=" + a); log.error("a=" + a, e) — the (Object, Throwable) overload
System.LoggerLOG.log(System.Logger.Level.WARNING, "a=" + a)
Floggerflog.atInfo().log("a=%s", a); flog.atSevere().withCause(e).log(…)

For Java, convertRecommendationsForJavaLogger runs once over the whole result set, before any placement pass (AnalysisEngine.ts). It converts the templates that build SLF4J text inline: timing, variable, exception, compliance and library rules. That includes the stream form .peek(entity -> log.info(…)), for every API, because the backend’s logger rename never reaches a mid-line call. Kotlin, Scala and Groovy use the same detector but render only the JUL case. Log4j 1.x, commons-logging and System.Logger in those languages still get the SLF4J shape (TODO in braceRegistry.ts).

Redaction. A logger with no placeholders gets its values concatenated into the message, and the positional secret redaction, which finds password={} by its hole, could not see them. concatRedaction.ts reads the concatenation back into holes and values, redacts the same slots, and writes the concatenation out again. Measured over the logger fixtures and the golden corpus, 15 statements that used to print a raw password are now redacted. Those were JBoss and Log4j 1.x files misread as JUL.

Go. The qualifier is the name the import binds (goImportName). An alias is the prefix, a dot import means bare calls, and a blank _ import binds nothing, so that library rule does not apply and the standard sink stands. Detection matches the exact import path:

  • go.uber.org/zap, but not …/zapcore;
  • github.com/sirupsen/logrus, spelled sirupsen or Sirupsen;
  • zerolog’s global form only for github.com/rs/zerolog/log.

The base github.com/rs/zerolog package has no global logger, so it needs a package-level zerolog.Logger variable, and that variable’s name becomes the receiver. A function-local logger := is not in scope anywhere else.

.NET. ILogger is not one type: Microsoft.Extensions.Logging, Serilog, NLog, Castle and nopCommerce each declare one, and only Microsoft’s has LogDebug. A bare ILogger field used to be read as Microsoft’s, which gave nopCommerce CS1061. detectDotNetLoggerConfig now classifies Microsoft.Extensions.Logging only with evidence:

  • using / Imports / open Microsoft.Extensions.Logging;
  • the fully-qualified name;
  • the generic ILogger<T>, since only Microsoft’s is generic;
  • an existing LogXxx( call.

A plain ILogger x beside using Serilog is a Serilog instance and gets x.Information(…). Beside using NLog it names the NLog instance. Log.Debug( alone is not Serilog, because Xamarin’s Android.Util.Log has the same call. Only using Serilog or a Serilog-only name (Information, Warning, Fatal) proves Serilog. A plain ILogger with none of this evidence resolves to no framework.

JavaScript. An imported logger is trusted only when:

  • the path is the project’s own module (relative, @/ or ~/, with logger in the path); or
  • the package’s export is itself a logger (winston, loglevel, consola).

A bare createLogger( counts only when it was imported from winston or bunyan. Vite’s createLogger returns a logger with no debug, and redux-logger returns a middleware, so it is never trusted. Instances built with pino(…), bunyan.createLogger(…), winston.createLogger(…) or log4js.getLogger(…) are still used.

Rust. detectRustLogFacade recognises use tracing, use log and log:: paths, but it is not wired into rendering yet. Output stays log::…! by default, and tracing::…! still comes from the existing library rule.

Placement — entry first, a duration after the call it times

Section titled “Placement — entry first, a duration after the call it times”

BankJava PRs #187/#188 had two ordering defects. First, every failing method opens with its query. The database timer and the entry log were both generated for that first body line, and the collision resolver let the timer win, so >>> Entering was written below the query. Second, a duration log’s only floor was its timer, so cashDeposit shipped save query executed in {} ms above the accountRepository.save(account) it claims to time.

  • Entry first. In resolveKeyCollisions, entry logs claim their keys before anything else. A timer generated for the same first body line joins the entry log as its last physical line, because the backend’s AnchorEmitter registers a database timer only from the last line it wrote.

  • Duration floor. Generators attach measures_line to a duration log. capturePlacementHints moves it into a side map and deletes the property, so the /analyze response stays byte-identical. The hint’s minKey is the first key past the end of the timed statement where a statement may begin, at a depth no deeper than the statement’s. Four passes floor a duration log at that key: javaFinalizeLogPlacement, resolveKeyCollisions, sanitizeJavaDatabaseDurationLines and the boundary pass’s upward walks.

  • A floored duration can share the tail key. When the floor is the key before the method’s closing brace and the exit log holds it, the two share the statement. That happens only when control really falls through to that key (shared/fallThrough.ts). If the call sits inside the return, or every arm of the last block returns, a log keyed there would be hoisted above the call or never run.

  • Safety nets with counters. enforceDurationFloors and enforceEntryFirst run after placement (pipeline/placementPasses.ts) and should find nothing. Their counters (durationFloorStats, entryFirstStats) are asserted to be zero on the fixtures:

    • A duration log keyed before its floor, or deeper than the statement it times, moves forward to the first free legal key. Where this pass owns placement and there is no such key, the log is removed and the orphan guard removes its timer; where another pass owns placement it is left exactly where its owner put it (see move-only, below).
    • A method whose entry log is not first gets the entry log back on its key, with the displaced timer merged behind it. Nothing is dropped. A merge the backend would refuse is counted as unfixable and left alone.

    enforceEntryFirst stands down for VB, Kotlin and Python, whose final keys belong to body-key placement, and for the JavaScript family.

    enforceDurationFloors no longer does, and that sentence used to end “the census still measures those languages”. It did — it measured them violating. Every duration violation in the golden corpus was in one of the exempted languages and all 23 covered languages had none: the exemption and the defect set were the same set. In userService.js the query sits on line 5 at depth 2 and the duration was keyed at line 8, the } of an if block, so it logged only when the row came back empty and never on the normal path.

    For those languages the net now runs in move-only mode: it may move a violator, never drop one. Dropping is right where this pass owns placement, but where another pass owns it a drop turns a mis-placed log into a lost line, which is what the census gates on. ownerViolations and ownerMoved count them separately, so the fixture assertions that violations stays at zero keep their meaning.

    Two things had to be true before it helped:

    • A duration log must declare what it times. jsTimingLogs set measures_line only on the nested external-call branch, so the ordinary DB duration carried no hint, capturePlacementHints built nothing and the net could not see it at all. It is now set on both DB duration recommendations, from findJsDatabaseCallStartLine.
    • The terminal-statement guard applies to every owned-elsewhere move, not just the RETURN_STRICT languages. A move out of a nested block can only go forward, and the next key at the statement’s depth is very often the line after a return. Without the guard the JS duration moved to the method’s closing brace — unreachable — and the Python one moved between return None and an except clause, which is not merely dead code but invalid Python that the syntax gate would revert. Both satisfied the two census invariants, which is worth remembering: the census measures depth and floor, not reachability.

    Measured over the 28 languages, baseline to candidate: durationDeeper 9 → 6 (python 3→2, svelte 2→1, vue 2→1) with lines 1896 → 1896 and logs 1786 → 1786 — no cell lost an emitted line and no invariant rose. The remaining six are refused rather than mis-placed: the corpus bodies are small and already carry a log on every legal key.

A query inside a return is announced, never timed

Section titled “A query inside a return is announced, never timed”

Nothing may follow a return, so a duration log for return self.db.query(...) is keyed before it — and the call has not run yet at that point. shapes.py find shipped

logger.info("find(account_id)=%s: find operation took %s ms", …)
return self.db.query("SELECT * FROM accounts WHERE id = %s", account_id)

measuring entry-to-guard: ~0 ms however slow the query is, which a dashboard reads as an instant query. Java has refused this since javaDbLogs.ts was written — “claiming a duration we never measured would be worse than omitting one” — emitting no timer and saying executing instead of took {} ms. Python now does the same, and the log still ships; what goes is the false number.

Two things were needed, and only the first is obvious:

  • pyGenDbLogs detects the returned expression, skips the timer, renders executing, and keys the announcement at dbOperation.line — an announcement goes before the call, a duration after it.
  • generatePyDatabaseTimingLogs declines the operation outright. Both Python rules reach the same generator, and only the timing path applies shiftForOffsetOneRule, so their keys are always exactly the rule offset apart — the cross-rule dedupe matches on an identical key and could never collapse them. Without this the file got two find operation executing lines, one apart. A response-time rule has nothing to say about a call it cannot time.

The census across 12 rule configurations and 28 languages, from dev before (9476648) to after (997f0c4):

MeasureBeforeAfter
Emitted lines65356628
entryNotFirst662
durationBeforeQuery2020
durationDeeper6746

dev’s own placement work between 3b34ce5 and 9476648 had already lowered entryNotFirst from 84 to 66; the two efforts together bring it to 2.

Recommendation objects fall (5990 → 5975) while emitted lines rise. A timer merged into its entry statement, or an exit merged into the duration after a method’s last query, is one object carrying two lines. Gate on lines, never on object counts: verify:javac reporting “393 → 381 logs written” on BankJava is 405 → 410 physical lines, with every <<< Exiting still present.

No cell lost a line. A pass-through return is captured into a local by the backend, not here; see Backend.

Known and pre-existing. These are not fixed, and the tests pin them as KNOWN:

  • JavaScript-family (JS, TS, Svelte, Vue) ordering and timing placement. That includes the four reviewed typescript census cells where durationDeeper went from 0 to 1. Base 3b34ce5 fails tsc on that file (TS2304 Cannot find name '_start_getUser_4'); the merged tree compiles it.
  • C, Go and Lua fold the timer and the duration log onto one key after the query, which measures 0 ms.
  • R writes .__logStart <- after the call it times.
  • F# places __sw after the query.
  • Java writes a one-statement constructor’s exit log above super(…).
  • Replayed from run 1569’s stored recommendations, which predate this analyzer, BankJava saveToken’s save duration is keyed above tokenRepository.save. It has not been re-measured with this branch.

Duplicate recommendations are suppressed at source

Section titled “Duplicate recommendations are suppressed at source”

src/core/analysis/passes/shared/duplicateRecommendations.ts runs unconditionally from placementPasses and finalizeAnalysis, so all 28 languages pass through it. Two rules:

  1. an identical statement at an identical key, across rules — two rules independently recommending the same line at the same anchor;
  2. a statement that is already a line of its own method — the file already contains it.

A suppressed recommendation is subtracted from generated and is not counted anywhere else: no DropCause, no reason code, no run_details field, no counter. It is not a drop that needs explaining to a user — it is a statement that should never have been generated twice, so the honest total is the one that never included it. That also keeps it outside the drop ledger’s coverage guard, which only pins sites that announce a removal without recording it.

Measured: a Python re-scan went from 70 already-present duplicates to 0; corpus-wide the already-in-own-method case went from 139 to 0 across 27 of 28 languages; a Python first scan went from 2 exact duplicates to 0. The golden corpus is 438/438 identical and no cell lost an emitted line.

placement_drops exists so a statement the customer does not get carries a reason. Its honesty depends on every remover calling recordDrop, and nothing was checking that end of it.

Measure it in the ledger’s unit. analysisOutcome.ts states the split, and differencing the wrong pair is how a phantom “164 unattributed statements” was once reported:

generated − placedentries the passes removed — the ledger’s unit, a cause is owed for each
placed − shippedentries folded onto a shared key — emitted, and not a loss at all

So the coverage gap is (generated − placed) − removals. Measured over the 28 languages it was 64 removals across 25 cells in 20 languages with no cause at all, and for C, Kotlin, Lua, Rust and Zig that was every removal in their timing cell — those files reported drops with an empty by_cause, a number that says “6 lost” and cannot say why any of them went.

Two passes filtered recommended_logs and called nothing:

  • dropDuplicateDurationLogs — the larger, and it loses statements two ways. The whole-statement case is obvious; the other is not. When a duplicate shares a statement with a real log, only its duration line is stripped and the log object survives. Both record, on the precedent durationFloors already set: a line that disappears without a cause is what the ledger exists to end, and “the object is still in recommended_logs” is not a defence.
  • dropReadersBeforeTheirTimer — removes a duration log keyed strictly before the key declaring the timer it reads. It does not fire on the golden corpus, which the first attempt at the fix proved by measurement: zero snapshots carried its cause. Fixed anyway, because a pass that removes without recording is invisible exactly when it does fire.

The new timer-read-before-declaration is classified a defect, not safety, for the same reason duration-floor:no-free-key is: the ordering is ours, so excusing it as safety would keep the drop rate reading lower than it is.

That took the gap to 25, and the residue turned out to be the opposite kind of event. A placement pass may fold an entry onto a key another log already claimed: the text ships on the shared key, but the log object disappears, and recommended_logs is what every counter measures. An unrecorded fold therefore reads downstream as “generated but never placed” — a merge counted as a loss, which is the failure merged-into-shared-key was added to the vocabulary to prevent.

It had three copies, which is why fixing the first only moved 25 → 18. The residual cells were exactly the languages served by the two files that had not been touched:

Fold siteServes
bodyKeyPlacement.tsVB, Kotlin, Python
returnSafePlacement.tsC, Lua, Go, Zig
rustPlacement.tsRust

All three now record it.

Result: 64 → 0. Every removal in the golden corpus carries a cause, with lines 1896 → 1896 and logs 1786 → 1786 — recording a loss, or a fold, must not change what ships.

A shared key is ONE statement, and the backend judges it whole

Section titled “A shared key is ONE statement, and the backend judges it whole”

Two passes fold several logs onto one key and join them with newlines, so the group travels as a single log_statement: enforceReturnSafePlacement, and resolveKeyCollisions since the key-sharing work. That is what lets a one-line method body carry an entry log and an exit log.

It also means one bad physical line discards every log beside it, because the backend’s filters are whole-statement tests:

Backend testWhereWhat it refuses
getLogStatement().contains("methodReturn →")LevelTwoDtoBuildera variable-tracking shape the pipeline never inserts
contains("<") && contains(">")FileUtil.addLineJava generics — and any merged entry+exit pair, since the markers are >>> and <<<

So a log that was going to be dropped anyway must never be folded onto one that was not. The rule is stated in keySharing.ts as its rule 3 and enforced on both sides — canHost() refuses an owner the backend discards, mayShare() refuses such a guest — with timingScaffolds.backendDiscards(stmt) as the single definition of the marker. The second test is handled the other way round, by trimming the group: goPostProcess.avoidBackendAngleBracketGuard.

backend-filters-statement was added to the drop vocabulary for the logs this now declines, classified safety rather than defect: a statement the backend can never write is not a placement failure, and counting it as one sends the next reader to fix a placement that does not exist.

A declaration must be written after everything that can drop its user

Section titled “A declaration must be written after everything that can drop its user”

Three passes declare something at file scope for the statements below them — Go’s import (logritelog "log"; logritetime "time"), Dart’s dart:io, Swift’s Foundation. All three are governed by one rule, and goPostProcess.ts has always stated it: only aliases a SURVIVING statement references are imported, which is why the pass runs LAST. An import whose only user was dropped is itself a compile error.

They sat above applyPlacementPasses, which was correct while the last dropper ran above them. It stopped being correct when droppers were added inside that pass, and nothing failed loudly — the imports were simply emitted for statements that no longer existed. They now run below applyPlacementPasses and applyFinalLanguageInvariants, in pipeline/languageImportPasses.ts.

Python hoists its import only into a block that already exists

Section titled “Python hoists its import only into a block that already exists”

applyPythonImportAvailability takes import time; out of the method body and declares it at module scope. It now declines to do so when the file has no leading import block, because the top of the file is contended: the backend inserts its own logger declaration there — two prepended lines, import logging + logger = logging.getLogger(__name__) — and a declaration keyed at line 1 loses. Measured on a fixture whose first line is class UserService:, the hoisted import was written inside the class body:

class UserService:
import time # binds UserService.time, not a module global
def get_user(self, ...):
__log_start = time.time() # NameError: name 'time' is not defined

Every instrumented method raised at its first call — py_compile and every linter perfectly happy. Python execution 100% → 40%. With no import block to join, the inline form stays: it binds time in the method’s own scope and cannot be displaced out of it. The asymmetry is BlockRewrite’s — a refusal costs a little polish, a wrong acceptance costs the customer a working file.

…and “no import block” was answering yes to a documented file (2026-09-30)

Section titled “…and “no import block” was answering yes to a documented file (2026-09-30)”

The scan for that block stopped at the first line that was not an import, a comment or a blank. A module docstring is none of those, and by PEP 257 it is the FIRST statement in a well-formed module — so on any documented file the scan answered 0, “nowhere safe”, and the hoist reverted:

"""Claims adjudication service.""" # the scan stops HERE and answers 0
import logging # the import block it was looking for

Nothing failed. The output is exactly what the file got before this pass was written, which is why it read as fixed — it was fixed, for undocumented files only. The scan now reads past a leading docstring (all four quote forms, single- and multi-line, r/u/b prefixed) and continues into the import region. It is the same region, read past one statement that cannot be anything else; a file with genuinely no imports still refuses, exactly as above.

hashlib had the same hole, and a comment saying it could not

Section titled “hashlib had the same hole, and a comment saying it could not”

With prompt_hashing on, the digest expression hashlib.sha256(...) is written into the statement by the redaction pass. The declaration was skipped entirely on a file with no leading import block — class ModelClient: on line 1 is enough — and the pass carried a comment asserting that a missing hashlib “cannot occur”, on the reasoning that a digest lives inside a method body “where a module-scope import was never what made it resolve”. That is the wrong way round: a name used in a method body resolves through the module globals, so module scope is exactly what made it resolve. Python has no implicit imports, and the customer’s file raised NameError: name 'hashlib' is not defined at the first instrumented call.

There is no inline form to put back for a digest, so one is written — import hashlib; on the statement’s FIRST physical line, never on the line the digest happens to sit on, because a log_statement always starts a statement while the digest can be on a continuation line of a wrapped call.

Both are pinned by tests/python-import-declaration.test.ts (npm run test:pyimports), which ends every case by RUNNING the instrumented file under python3. That is the point: each of these defects produced a correct log line in a file that does not execute, and an assertion about the text is satisfied by exactly that.

Generation options — three opt-ins that had no door

Section titled “Generation options — three opt-ins that had no door”

skip_trivial_methods (LG-7), prompt_hashing (LG-5) and structured_fields (LG-6) are fields on AnalysisRequest, each honoured by a pass in finalizeAnalysis, each with its own tests — and until 2026-09-30 no route copied any of them out of req.body. They were reachable by calling the engine in-process and by nothing else: not the backend, not a customer, not curl. A feature that is finished and unreachable looks exactly like a feature that is finished.

OptionDefaultWhat it doesWhy that default
skip_trivial_methodsoffWithholds entry/exit logs from constructors, accessors and one-line pass-throughs. Every method withheld is named back in skipped_trivial_methods.Skipping a method IS emitting less, and this service never emits less by default.
prompt_hashingoffPrints a stable 12-hex digest of a model prompt instead of redacting it outright, so prompt versions stay comparable. Python only.Off, the prompt is redacted, which is already safe. No other supported language has a stable digest that is one synchronous expression with no new dependency.
structured_fieldsonAppends ECS-mapped key/value fields; the message string is retained byte for byte.It only ADDS — every existing wildcard query keeps matching — and an opt-in nobody opts into leaves the index exactly as the customer test measured it: 14 ECS fields, all of them envelope.

The defaults do not agree, and that asymmetry is the finding’s rather than a preference. The two that are off remove or rewrite what the customer sees; the one that is on only appends. OPTION_DEFAULTS in src/api/generationOptions.ts is the single authority — the backend and the UI both mirror it and neither re-implements it, because a default duplicated across three services is a disagreement waiting to happen in which every side keeps answering, just differently.

They are read in ONE place, src/api/generationOptions.ts, by the single-file /analyze path, the files[] branch, /analyze/batch, /analyze/stream and /analyze/auto — four call sites is exactly how many it takes to drift, and a scan must not mean something different depending on how the backend happened to send it.

Two properties are load-bearing and both are asserted in tests/generation-options-wire.test.ts (npm run test:genoptions), against the request object the engine was actually handed:

  • Absent is not false — it is “not said”, and the default answers. An option the caller did not send is left off the request entirely. Two reasons, and the second is the one that bites: the request is reduced into the analysis cache key, so a false that used to be absent cold-starts the cache on every deployed scan for a value meaning the same thing; and for structured_fields absent and false do not mean the same thing at all, so a helpfully materialised false would turn OFF a feature nobody asked to turn off, silently, on every rule configuration that predates it.
  • The cache key records DEVIATIONS, not values. A caller that sends nothing and one that spells out the defaults ask the identical question and share the entry; structured_fields: false hashes as structured_fields=off. Keying on the values instead would hand half the callers a private copy of an answer the other half had already computed.
  • They are IN the cache key. Each one changes the statements emitted for identical bytes, so two requests differing only by an option are two different questions. Without this the second is answered with the first one’s result — the defect analysisCacheKey’s own header describes for the filename, one field over. The contribution is empty when no option is on, so a caller that sends none hashes to exactly the string it hashed to before.

Only === true turns one on. A JSON body can carry "false" — a non-empty string, and truthy — and the sibling flags on this surface (skip_file_filter, only_if_changed, strip_existing_logs) all read === true for the same reason.

An orphan timer is a defect that grows on the next scan

Section titled “An orphan timer is a defect that grows on the next scan”

A start timer nothing reads is not untidiness. It is an unused local — a hard compile error in Go, Rust, C and Zig — and it is self-perpetuating: the next scan reads the surviving timer back, concludes the method is being timed, and finally adds the duration log the first scan half-placed. The file grows on every re-scan and never reaches a fixed point. That is the general shape of the idempotency defect: a log dropped for want of an anchor that the drop itself creates.

The third shape: a rule true of ONE physical line, applied to the whole group (2026-09-24)

Section titled “The third shape: a rule true of ONE physical line, applied to the whole group (2026-09-24)”

The analyzer folds co-keyed logs into ONE log_statement joined by \n, and the writer adds that whole thing as a single element of updatedLines. Every line-oriented consumer then reads the group as one opaque line whose first token belongs to somebody else’s log. Three defects came out of that one fact, and each had been diagnosed as something else first.

TimerDialect.declares() could not see a declaration inside a merged element. The pattern is anchored ^ on line.trim(), which for a merged element starts at _logger. Printing the census TimerScopeRepair judges GetUser by:

ourDecls=[] theirDecls=[] readers=[19]
line 13 declares=FALSE isOurs=true <- the writer's own declaration

With ourDecls empty and a reader present, hoist takes its INSERT branch and adds a second declaration — CS0128: A local variable named '__logStart' is already defined in this scope, which fails dotnet build, which is the entire C# execution dimension.

OrphanTimerPruner removed the whole insertion for an orphaned timer, and an orphaned timer is routinely a PASSENGER in another log’s statement. One lost key contest in a three-line body took the duration log (legitimately), then the ENTRY log, which has no timer of its own.

And the C# fixture did not compile, so DotNetBuildGate — which builds the PROJECT, not the file — could judge none of the five C# files. execution was recorded null and EXCLUDED from the composite at weight 2.0, the joint-heaviest of thirteen.

Measured across the three fixes: csharp 0.785 → meets its 0.889 baseline (execution 0.000 → 1.000), and java +0.02 — the pruner fix is not C#-scoped, Java merges statements the same way. 26 rows unchanged, none down, mean 0.8921 → 0.8936; the ratchet lost csharp composite: 0.889 → 0.875 and gained nothing. Backend 3412 tests, 0 failures — eighteen MORE tests running than before, not fewer.

Still open, stated rather than traded away. FetchRemote’s EXIT log is folded onto the duration log it rides behind — deliberately, because walking back puts <<< Exiting above code that still runs (C# SavePin) — and dies when that host is refused at InsertionPlacement:74. There is no anchor left to re-home the guest to at write time, and forbidding the ride re-breaks SavePin. So csharp idempotency remains 0.000.

The second shape: a log never GENERATED for want of a position the conversion creates (2026-09-24)

Section titled “The second shape: a log never GENERATED for want of a position the conversion creates (2026-09-24)”

The orphan timer above is a log dropped. There is a second shape, and it is silent — the statement is never proposed at all, so nothing reaches the drop ledger to record it.

generateMethodExitRecommendations skips a method it judges degenerate:

method.endLine <= (method.startLine + 1)

A genuine ONE-LINE member has endLine === startLine, so it matched, and no exit log was ever generated — with placement_drops.by_cause empty, because the statement did not exist to be refused. The guard’s own purpose is real but different: an outer function TRIMMED by the overlap correction also lands there, and computing an exit for it bumps the statement into a nested function.

The two are separable and the separator already existed. singleLineBody is set by the PARSER on exactly the shapes an opener recognises, and a trimmed parent’s line is a bare def f():, which neither test accepts. So the guard no longer fires on a member that is about to GAIN a body, and convertSingleLineBody gained the position to put the statement in — the END of the opened body.

Measured on cases/shapes.py::touch (def touch(self, key): self._cache[key] = True): pass one opened the body and wrote the entry log; pass two found the position pass one had just created and added the exit log. python idempotency 100% → 0%, composite 0.948 → 0.896, on one method of one fixture. Fixed: 0.90 → 0.95, pass1 and pass2 byte-identical for every file.

Where the exit log goes inside the opened body is decided by the line the CONVERSION WROTE, not by the language’s valueSource. openCSharpExpressionBody settles C# by construction —

const returns = returnType !== 'void' && !throws;
lines: [head + ' {', bodyIndent + (returns ? 'return ' : '') + expr + ';', indent + '}']

— so a converted C# body is always return expr;, throw expr; or expr;, never a bare tail expression, and the hazard valueSource: 'both' guards is not present. Across the seven C# one-line shapes, six gained a line on every re-scan before this and all seven are stable after. The five languages whose tail genuinely IS the value — scala, ruby, rust, kotlin, swift — still refuse, verified rather than assumed.

Two ceilings that mistook a boundary for body (2026-09-24)

Section titled “Two ceilings that mistook a boundary for body (2026-09-24)”

Same family, in statementBoundaryPlacement:

  • A word terminator is a closer. bodyEnd + 1 is right for an OFFSIDE body, whose last line is a statement; a word-end body ends on a TOKEN. On an EMPTY VB body the offside walk breaks on the terminator’s own dedent and reports no body, so bodyEnd falls back to the parser’s endLine — the End Sub line — and +1 stepped over it. BC30188: Declaration expected, measured on vb-codeconverter ByRefArgument.vb in 27 of vb’s 30 rule configurations.
  • A key past the file’s last line is still judgeable. The bounds that decide whether a key is inside its method come from the OWNER, not from the line under the key; where the last method closes on the file’s last line, “one past the closing brace” and “one past the end of the file” are the same key. A typescript compliance log shipped at MODULE scope referencing three out-of-scope variables.

Both bounds were measured, not reasoned, and the first attempt at each was too wide. The terminator fix first read “any delimiter that is not offside” and regressed kotlin 0.93 → UNGRADED and lua 0.95 → 0.90, because placementContractFor resolves a contract for EVERY catalogued language — the unlisted ones default to brace — so “declares a delimiter” excluded nothing. The file-end fix first applied to every language and regressed python 0.948 → 0.896 for the same reason in reverse: for an offside body one past the last line is INSIDE the method. placementContractFor('lua') settles the first in one call; reading the table does not.

Three guards answer it, at three different moments, and each sees something the others cannot:

guardwhenjudges
dropOrphanTimingScaffolds (analyzer)after every placement passthe RECOMMENDATION set
pruneOrphanedTimerStarts (backend)before placementthe INSERTION set
removeOrphanedTimerDeclarations (backend)after the writewhat was actually EMITTED

Only the third can see a duration log that a placement guard refused at write time, and that is the case that reaches a customer.

The third sweep gated on TimerPairIndex.isDbTimerStartLog, which asks the NARROW question — our variable name beside a JVM/.NET clock call. Shell emits __logStart=$SECONDS: capital S, no spaces, no recognised clock. It was invisible, so the timer landed, its duration log was dropped as no placeable anchor, and pass two grew the file by exactly that line. Shell idempotency 100% → 0%, placement 100% → 92.9%, composite 0.972 → 0.901.

None of those three consumers is the post-write sweep, so it takes the union privately and leaves the shared classifier alone. The union is strictly additive, and what it adds is bounded by the four guards already there: the line must not be the customer’s own, must carry no log, must name one of our timer variables, and nothing in its method may read it.

Measured on the full board, nothing else changed: shell idempotency 0.000 → 1.000, placement 0.929 → 1.000, composite 0.901 → 0.946, and every other language unchanged to three decimals. The sweep now also clears orphans it could never see in C++ (std::chrono::steady_clock), Elixir (System.monotonic_time), Ruby (Time.now) and JS (Date.now()) — dead code in each, which is why none of their scores moved.

shell rulePlacement falls 0.667 → 0.500, and that is the metric being corrected. external_service_calls_response_times was counted as having placed a log because its orphan timer landed. A timer nothing reads is not a log. The rule places nothing in shell, which is now what the board says — the same blind spot ruleCoverage has, seen from the other side.

=> members have no { … }, so the walk that computes every other member’s end runs past them and stops at the next member that HAS a block. Every arrow member therefore claims the rest of the class:

memberextent computedextent it has
ToDeviceStatus (arrow on the next line)24 – 4024 – 25
Describe (arrow on the declaration line)29 – 4029
Combine (a real body)34 – 4034 – 40

A method’s extent is what clampLogsToMethodBody judges “inside the method it names” by, so the clamp has nothing to object to. Describe’s exit log was keyed at line 39 — inside Combine — and written there:

_logger.LogInformation("<<< Exiting Combine(left={Left},right={Right})", left, right);
_logger.LogInformation("<<< Exiting Describe(id={Id})", id);
return string.Join(",", parts);

id is Describe’s parameter. It does not exist in Combine. That is CS0103 — a build break, not a misplaced log.

The parser now clamps an arrow member to its own expression, asking the same two questions the backend asks (arrowIsTheBody, arrowOnNextLine) so the two halves cannot drift. A { before the arrow — or a next line that opens one — is a real body: that is the counter-case, a declaration whose parameters merely wrap, and it must still be instrumented. A lambda inside such a body is never reached, because only the declaration line is examined.

Why it went unnoticed. The pristine fixture does not compile either: public override string ToDeviceStatus overrides nothing (CS0115), so the .NET build gate cannot validate that file at all, and a genuinely build-breaking injected log passes unremarked. A gate that cannot build the clean file cannot judge the instrumented one.

Measured: csharp falsePositives 0.750 → 1.000 (baseline parity), composite 0.859 → 0.874, and every other language unchanged to three decimals.

Nothing is found or placed inside /* … */. JobSearch PR #677’s jobs.js opens loadJobsTable with a 43-line commented-out DataTable config. beforeSend: function (xhr) { inside it was parsed as a method, and its entry and exit logs were written into the comment, counted as placed, and unable to run.

  • The Java, brace-language, .NET and JavaScript parsers skip a signature that starts inside a block comment.
  • A key whose line begins inside a comment is refused. The line that opens a comment is still a legal key: a key on a Javadoc’s first line puts the log above the doc comment. So is the line after the closer.
  • PowerShell also refuses its <# opener line, because comment-based help must stay the first thing in a function body.
  • The fence is a column of lexicalProfile.ts: c; c-nested for Rust, Swift, Kotlin, Scala and Dart, where comments nest; and powershell. JS and TS take comment ranges from Babel’s ast.comments. Every other language uses a string-aware scanner.
  • An unterminated comment is ignored rather than blanking the rest of the file.
  • Brackets are counted with comments blanked, so a { in a comment no longer moves a method’s end.

The backend refuses the same positions at write time (INSIDE_BLOCK_COMMENT).

The function body opener is found by a paren-depth scan (shared/functionBodyOpen.ts) that accepts { alone on the next line. JobSearch had showReviews’s entry log inside reviews.forEach and bindEvents’s entry log inside a click handler because the old suffix test could not see an Allman brace. A body that closes on its opener’s line is not a block body.

A call is not a definition. The line parser’s method-shorthand pattern (name(args) {) rejects three shapes (isCallNotDefinition, parsing/jsMethods.ts):

  • a name reached through . or $(;
  • an argument list that contains function or =>;
  • the name function.

A callback passed to a call is not a method, so $(document).on("click", "#sendMsg", function () { is no longer a method named on.

  • Routes are still methods. The Babel AST pass names <router>.<verb>('/path', handler) from the verb and the path as an identifier-safe name, for get, post, put, delete, patch, head, options and all. Arrow handlers and wrappers such as asyncHandler(...) are included:
    • app.get('/users', function (req, res) {…}) becomes GET_users;
    • router.post('/items/:id', async (req, res) => {…}) becomes POST_items_id;
    • a path of / becomes GET_root.
  • Path-less middleware is not instrumented. app.use(function (req, res, next) {…}) gets no entry or exit log. Anonymous path-less middleware has no stable identity to name it by. Duplicate names break the passes that look methods up by name, and every new detection moved logs on real code. The old use() name was an accident of the shorthand regex, which never matched arrow handlers at all. This matches the other languages: Go func literals, Kotlin lambdas and Ruby blocks are not methods either. Only named definitions and routes are.
  • Quoted keys and assigned functions. "render": function (data, type, row) { is the method render, not function. A quoted key must be an identifier, so 'text/plain; charset=utf-8': function gets no name. A function expression assigned to a property ($.fn.showElement = function () {) is named by that property. This only renames the old function (…) { shape and detects nothing new.

Known and pre-existing follow-ups:

  • The by-name lookups in coordinateLineNumbers and deduplicateAcrossAllMethods still lose one-liner exit logs when several classes share a method name (hono handler.ts).
  • stringFence.ts counts backticks naively.
  • Scala variable tracking can find a declaration inside a comment.
  • A C++ #include anchor can follow an #include that sits inside a comment.

npm run test:languages chains every generation suite with &&, so the first failing script hides all the scripts after it. At 3b34ce5 six scripts failed (test:langcov, test:brace, test:logspec, test:throwsite, test:bucket4gen, test:gapengines); at 9476648 and 997f0c4 every script in the chain passes. Still run the scripts one at a time and compare each one against the same base run, including its failing check lines.

Scripts added to the test:languages chain:

ScriptProves
test:loghonouredEvery template renders the rule’s level, TRACE included
test:levelratchetNo new hard-coded level anywhere in src/ (see Log levels)
test:bankjavalevelsThe live BankJava and JobSearch rule configurations on their real files: DB timing at WARN, every JobSearch line at TRACE
verify:levelsREAL toolchains at every level: Ruby services executed against Logger.new(StringIO), and javac with Lombok 1.18.36 and slf4j-api 2.0.16. A missing toolchain is UNVERIFIED and exits 2
test:jvmlogger · test:loggerdetect · test:loggerapiJVM API detection and renderers, logger detection across languages, logger API and brackets
test:entryfirstEntry, then timer, then query, then duration, read off the instrumented file in all 28 languages, with the query first and second
test:bankjavaplacement (also verify:javaplacement)Java placement audit on the BankJava fixtures
test:bankjavajavacREAL javac and Lombok on the instrumented BankJava AccountServiceImpl
test:commentcallbackThe JobSearch #677 comment and callback defects, on the original jobs.js and candidates.js
test:expressroutesRoute handlers are named by route, and path-less middleware is not a method

Real compilers, outside the chain. A missing toolchain prints SKIPPED and never counts as a pass.

  • npm run verify:jvmloggers instruments the 11 fixtures under tests/fixtures/loggers/java/ with every rule at all 5 levels, then compiles them.
    • REAL jars: slf4j-api 2.0.16, lombok 1.18.36 (also as annotation processor), log4j-api 2.24.3, commons-logging 1.3.3, jboss-logging.
    • STUB: Log4j 1.x and Flogger. A stub pass proves the call shape only.
    • Fails on a javac error other than an unresolved local variable, a password value on an injected line, or the wrong entry method.
    • It also fails when a fixture has fewer injected lines, or more placement errors, than the SLF4J fixture at the same level.
  • npm run verify:gologgers runs go build on 5 fixture packages: std, logrus imported as log, zerolog global, a zerolog instance, and zap.
    • REAL: the Go toolchain and standard library.
    • STUB: logrus, zerolog and zap, as replace modules built with GOPROXY=off.

Census. tests/golden/census.ts (npm run golden:census) counts what the engine emits.

  • --record <outRoot> [--engine <treeRoot>] [--sets …] writes a shadow root: every file of the 28-language corpus times every rule configuration.
    • 6 committed sets: lifecycle, errors, tracking, combined, timing, compliance.
    • 3 level sets: bankjava, alltrace, mixed.
    • 3 placement sets: entrytiming, bankjavaplacement, everything.
  • census.ts <baseline> <candidate> exits 1 in two cases:
    • any <language>/<ruleSet> cell emits fewer physical lines than the baseline;
    • any invariant count rises.
  • The invariants are computed from source and keys, with the product’s own computeBoundaries:
    • entryNotFirst: a line of method M is written above M’s >>> Entering — except lines belonging to a statement that carries replaces_line, see below;
    • durationBeforeQuery: a duration log is keyed at or before the last line of the statement it times;
    • durationDeeper: a duration log is keyed in a block nested deeper than that statement.
  • keyInsideComment and phantomMethod are reported and never gated.
  • Never record into the committed snapshots. Record the base into a shadow root and point GOLDEN_SNAPSHOT_ROOT at it.

Reviewed intended deltas. tests/golden/intended-deltas.json excuses exactly the change a reviewer looked at. tests/golden/intendedDeltas.ts validates it.

  • Every entry names a cell, integer from and to, and a reason.
  • With invariant, a rise is excused only while the baseline reads from and the candidate reads at most to.
  • Without invariant, a loss of emitted lines is excused only while the baseline reads from and the candidate reads at least to.
  • An unbounded, unknown or duplicate entry throws.
  • An entry that excused nothing is reported STALE, not failed.
  • Today it holds four entries: typescript/{alltrace,entrytiming,everything,mixed}, durationDeeper 0 → 1.

Method census. tests/golden/method-census.ts counts the methods the parser finds per language, because a method that silently stops being found costs every log it would have had. It has no npm script: npx ts-node tests/golden/method-census.ts --compare <before.json> --allow tests/golden/method-census-allowlist.json [extra dirs]. A method may disappear only when the allowlist names it with a reason. New methods are reported and never fail.

At 997f0c4 (now dev), each gate against the same run on 9476648:

  • tsc 0; file-size ratchet 0; lint:cycles clean; lint:perf identical to base (raw-line-split 85/71 fails on both).
  • test:languages: 88 of 88 scripts pass (base 76 of 76; the 12 new scripts are in the table above).
  • The other test:* scripts: the same 17 fail on both sides, with byte-identical failing checks. Sixteen of them need the dev corpus, which is not on the dev VM (corpus fixture is present), so those suites measure nothing on either side.
  • verify:javac 11 clean / 1 broken (EnterpriseService), with the same errors as base.
  • verify-log-level 401 → 412 of 440 cells honour the requested level; no cell became less exact.
  • verify-language-matrix broken cells 3 → 2, none newly broken.
  • verify:jvmloggers 449/0; verify:gologgers 107/0.
  • Census exit 0, with 4 of 4 intended deltas still applying against the new base; method census exit 0.
  • golden:verify differs in 111 cases (base: 104, because the committed snapshots predate dev’s level prefixes). The 7 new cases are all fixes: a stray ) removed from duration text, log. replaced by the class’s own logger., a duration keyed after its query, and a TypeScript entry log moved out of an if.

The analyzer does not run a fixed battery of “strategies” over log data — that description belonged to an earlier service. What it applies is the project’s template: a list of logging rules (analysis_type plus a level), such as method_entry_logging, method_exit_logging, exception_handling, class_variable_tracking, database_logging, method_variable_tracking, external_service_calls_response_times, or a framework’s <framework>_compliance rule. Each rule yields existing and recommended log statements per method; the backend aggregates them into the severity buckets described in Reading a scan summary. The live list is GET /analysis-types (internal token).

Compliance labels — the citation follows the RULE, not the generator

Section titled “Compliance labels — the citation follows the RULE, not the generator”

29 frameworks share nine generators. A compliance obligation is classified into one of nine event classes (identity, authorization, object_access, change_management, data_protection, monitoring, incident, integrity, audit_record) and the class is served by whichever generator is already proven for it — which in practice means SOC 2’s and HIPAA’s, because their placement is the part that is correct in all 28 languages. Reuse is the point: a framework seeded tomorrow inherits placement that works instead of acquiring new placement bugs.

What must not be inherited is the label. A borrowed generator hard-codes its own audit tag and its own control reference, so retagAudit (src/core/recommendations/libraryRules.ts) rewrites both to the framework that owns the rule.

A borrowed control code is never translated — [CC6.6] is SOC 2’s citation and there is no honest mechanical mapping from it to a SOX ITGC reference. But the library rule states its own reference, and quoting that is reading rather than inventing. controlCodeOf takes a short control id from the rule’s complianceRef and refuses unless the parse is complete and unambiguous: exactly one control token, running to the end of the reference’s head.

Rule’s own complianceRefEmittedWhy
SOC 2 TSC CC7.3 (crosswalk: NIST IR-4 / AU-6)[CC7.3]one token, complete
ISO/IEC 27001:2022 Annex A.8.24[A.8.24]one token, complete
GLBA / FTC Safeguards Rule 16 CFR 314.4(j)[16 CFR 314.4(j)]the (j) is part of the id, not editorial
NIS2 Directive (2022/2555) Art. 21(2)(c)[Art. 21(2)(c)]one article, complete
FedRAMP Rev 5 / NIST 800-53 AU-2/AU-3/AU-12nothingthree controls — quoting one would be a truncation
ISO/IEC 27001:2022 Annex A.8.15 / A.8.16nothingtwo controls
16 CFR 314.4(a)-(b)nothingquoting …(a) would silently drop -(b)
NYC Local Law 144 §20-871(b)nothing§20 is not a section of that law
SOX ITGC — computer operationsnothingthe rule names no control id

Measured over the 130 seeded coverage rules: 25 of the 112 retagged rules can quote their own control id; 87 cite nothing, which is the honest rendering of a reference that states no control. A missing citation is acceptable; a wrong one is not.

The rationale is rebuilt the same way — from the rule that selected the entry (<rule id>: <hint>) rather than from the lending framework’s prose.

There is no PM2 and no /root/logrite any more (both went with the 2026-08-03 rebuild). The analyzer is a container on each VM, owned by docker compose and built from Dockerfile.ci. A push to main deploys dev through the deploy watcher; prod moves only when promoted from the management console. See Deployment → Platform.

Terminal window
# build and type-check locally (tests run on the host; services do not)
npm ci && npm run build # tsc → dist/
npm start # NODE_ENV=production node dist/cluster.js — needs the env below
# on the dev VM: logs of the running container
docker logs -f "$(docker ps --format '{{.Names}}' | grep ^c12g1iwg)"

Non-secret names only; values live in the compose environment on each VM.

VariablePurpose
PORTListen port — 9000 prod, 9001 dev
ANALYZER_INTERNAL_TOKENRequired. Shared secret the backend sends as X-Analyzer-Token; the service refuses to start without it
ANALYZER_ALLOW_NO_AUTHtrue lets it start without the token — local runs only
AI_PROVIDER, LLM_MODELLLM provider and model (neuralwatt, default deepseek-v4.1-flash)
NEURALWATT_API_KEY / GROQ_API_KEY / LLM_API_KEYProvider credential for the provider selected
OPENSEARCH_URL, OPENSEARCH_USERNAME, OPENSEARCH_PASSWORDCluster used by the agent-scan path
WARDEN_BACKEND_URL, WARDEN_INTERNAL_TOKENWarden control plane, for the /internal/scan/* routes
METERING_BACKEND_URL, METERING_INGEST_TOKENLLM usage metering (fail-open)
ANALYZE_MAX_INFLIGHTAdmission-control limit on the analysis routes; unset = no limit
AGENT_SCAN_ENABLEDPeriodic collector-agent sweep only; ingest-triggered scans run whenever WARDEN_INTERNAL_TOKEN is set

Suggested severity and log level for compliance rules (2026-09-17)

Section titled “Suggested severity and log level for compliance rules (2026-09-17)”

Every compliance rule used to arrive in Configure Rules at INFO, whatever it was for: a leaked credential and a routine access record looked identical, and the level column carried no opinion because nothing produced one. The analyzer now derives a suggested severity and log level per rule, and serves them. A suggestion is a DEFAULT and a piece of advice — effectiveLevel() returns a user’s saved level whenever they have one, so nothing here can overwrite a choice.

The one file to edit is src/core/compliance/ruleSuggestionTable.ts. Three tables, each row carrying its reason:

tablekeyed onchange it to affect
LEVEL_BY_EVENT_CLASSthe rule’s event classevery coverage rule of that class
LEVEL_BY_LEAK_SEVERITYthe rule’s severityevery leak rule of that severity
RULE_SUGGESTION_OVERRIDESone rule idthat rule only, beating both

Then npm run report:rulesuggestions (regenerates the committed review table at docs/compliance/RULE_SUGGESTIONS.md) and npm run test:rulesuggestions. A framework newly seeded in the backend needs no edit here at all — the derivation has no allowlist, and a test proves it with an invented rule id.

Coverage rules are keyed on event class because the level is a property of the event. Leak rules are keyed on severity instead, and that asymmetry is deliberate: the classifier reads the rule id, and a leak rule’s id names the data, not an event — 15 of 18 fall to the default class, and the three that do not are keyword accidents (soc2_log_leak_credentials lands on identity because of the word “credential”). Keying advice on that would launder an accident into a recommendation.

One divergence is declared rather than hidden: audit_record suggests info where its generator floors at WARN, because audit_record is the default class of all 29 frameworks and WARN would make the majority of every framework’s obligations a warning.

1. analyzer ruleSuggestionTable.ts ← the only edit
2. analyzer GET /compliance/rule-suggestions[?framework=] (server.ts)
3. backend RuleSuggestionService (5-min cache, fail-soft)
├─ GET /api/v1/warden/library/rule-suggestions → the UI
└─ ComplianceAnalyzerRulesSeeder → a NEW rule's stored level
4. frontend utils/ruleSuggestions.js → RuleToggle → RuleSuggestionNote

No backend or frontend release is needed for a changed suggestion; the display picks it up within five minutes of the analyzer redeploying. Every hop degrades to today’s behaviour, so deploy order does not matter — until the analyzer ships, the proxy returns {} and every row renders as it does now.

  • CC7.2 “Security incident detected” is emitted only for a method whose name is detection vocabulary (detect|threat|incident|alert|alarm|anomaly|suspicious|intrusion), placed as the first statement of its detection branch; a method without such a branch gets nothing. Before, every operation the SOC2 detector matched (for example a cache config method) got it.
  • CC7.3 now says “Security event recorded” at INFO.
  • SOC2 monitoring and authorization detectors match whole camelCase/snake_case identifier words, so log no longer matches catalog or login, and grant no longer matches emigrant. Audit-record verbs read the method’s first word; Spring configurer callbacks are not state changes.
  • The vendored compliance catalogue follows the backend’s HIPAA 1.4.1 (identifiers URL rule).

Documented from analyzer main at 1845dd46.

  • Agent scans read by arrival, not event time. The periodic and per-agent log scans select lines by ingestedAt (≥ the window start), so backfilled lines with old timestamps are scanned.
  • Paged, and honest when they stop early. A scan pages with search_after on [ingestedAt asc, _seq_no asc], 500 per page and up to 20 pages per run (AGENT_SCAN_PAGE_SIZE, AGENT_SCAN_MAX_PAGES; AGENT_SCAN_MAX_DOCS is gone), with track_total_hits. When more remain, the outcome is PARTIAL (counted as examined) and a warning is logged instead of silently stopping at 2000 documents.
  • Bulk partial failures. When an OpenSearch _bulk reply has errors: true, only the 2xx items count as indexed and the first error’s type and reason are logged.
  • format=hierarchical returns real values. The package comes from the file’s package line (Java/Kotlin) or is omitted; abs_file_path is the real path (also accepted per file in batch requests); line numbers come from the content; percentages are computed from recommended and existing counts; the logger declaration is per language (Java/Kotlin @Slf4j, Python logger = logging.getLogger(__name__), omitted for JS/TS and others). The fixed demo constants are gone.
  • logAdded matches control ids only. A compliance code must be a control id (CC…, A…, 164.…, Art …), keyed by method and overload, so any bracket tag no longer counts.
  • Regex safety. A custom rule pattern is unsafe when a quantified group contains a quantifier with an upper bound above 1, or when it has more than 3 unbounded quantifiers.
  • /health commit is the first 12 characters on both estates.

Reasoning-model token budgets (2026-10-14)

Section titled “Reasoning-model token budgets (2026-10-14)”

deepseek-v4.1-flash is a reasoning model: its reasoning tokens count against max_tokens. /rules/propose asked for 800 and every custom-rule preview on dev failed — the model reasoned past the budget and returned finish_reason: "length" with no content, reported as “Empty response from NeuralWatt API” and shown to users as “the assistant was unreachable”. It now asks for 4096 with low reasoning effort (the Warden judge’s settings), the four key-findings generators moved from 1000 to 4096 for the same reason, and an empty reply cut at the budget now raises “NeuralWatt reply truncated at max_tokens=N (reasoning_tokens=R) — raise maxTokens”.

Collector-log scanner: TLS, and the judge flood (2026-10-14)

Section titled “Collector-log scanner: TLS, and the judge flood (2026-10-14)”

Analyzer d8997f4 (prod d8997f44b881).

The scanner never worked. AgentLogScanner failed every scan on both estates with unable to verify the first certificate against the self-signed https://opensearch:9200 (2,191 times in 6 h on dev). The _search (scanWindow.ts) and _bulk (indexFindings.ts) calls now use an https.Agent controlled by OPENSEARCH_VERIFY_CERTS (default false; verification only when exactly true, same as the MCP server).

VariableDefaultMeaning
OPENSEARCH_VERIFY_CERTSfalseVerify the OpenSearch certificate
AGENT_SCAN_ENABLEDunsetThe periodic scan. Unset on both estates
AGENT_SCAN_TRIGGER_WINDOW_MIN10Window, in minutes, of a triggered scan — manual and ingest-triggered alike. A triggered scan reads it newest first (ingestedAt desc), so a truncated scan drops the oldest lines, not the batch that triggered it
AGENT_SCAN_TRIGGER_DELAY_MS6000Debounce before a triggered scan reads the window (2026-10-21; was about 4 s)
AGENT_SCAN_TRIGGER_REREAD_MS5000When the first read of a triggered scan misses the batch that triggered it, one re-read after this delay (2026-10-21, FM-1)
AGENT_SCAN_JUDGE_MAX_LINES80Unique lines per scan, per agent, sent to the NL judge. 0 turns the judge off; the deterministic layers still run
(constant) LINE_JUDGE_TIMEOUT_MS12 000Bound on one line’s NL judge, shared with the dry run. On expiry the model call is cancelled and the line keeps its framework and pattern findings (2026-10-23). The judge is shown at most 200 characters of a line, and redaction reads at most 64 KB of it (LINE_REDACT_MAX)
AGENT_SCAN_SKIP_DEMO_WORKSPACESunsetComma list of workspace ids whose seeded demo lines (demo: true) are not scanned. Dev 3103, prod 302,303 (2026-10-20). Non-numeric entries are ignored
(constant) BUNDLE_TTL_MS60 000How long a fetched logs bundle (/internal/warden/logs/resolve) is cached per workspace and agent, in ms, for the periodic sweep. Triggered scans fetch a fresh bundle. Not an env var (agentScan/config.ts)
(constant) BUNDLE_CACHE_MAX500Bundle cache entries per worker, least recently used evicted (2026-10-21; it never evicted before)

Manual trigger. POST <analyzer>/internal/scan/agent with {"workspaceId","agentId"} and header X-Warden-Internal-Token runs a scan scoped to that agent over the last AGENT_SCAN_TRIGGER_WINDOW_MIN minutes, after AGENT_SCAN_TRIGGER_DELAY_MS.

A scan only finds something for an agent that a business-rules policy with Collector logs reaches (since 2026-10-15: any non-Off policy with the logs feature, workspace-wide unless narrowed by collectorIds; formerly a logs-scoped rule set attached with warden_rules attach_to_agent). A workspace-wide logs policy therefore reaches every collector — narrow it to avoid the judge flood below. The demo seeder writes the findings of its seeded agents (demo seeder), so the analyzer skips their lines (below).

Seeded demo lines are not scanned in the listed workspaces (analyzer 54ec836, 2026-10-20). fetchScanWindow (agentScan/scanWindow.ts) is the one read both scan paths share, periodic and ingest-triggered. When AGENT_SCAN_SKIP_DEMO_WORKSPACES is set, it adds {bool: {filter: [{term: {demo: true}}, {terms: {workspaceId: [...]}}]}} to must_not. The list is set by the operator and a tenant cannot set it, so a tenant cannot exempt its own lines by sending demo: true. Real collectors carry no demo field and are still scanned. The policy dry runs (/internal/scan/evaluate) evaluate the lines they are given and are unaffected.

Why: logs policies reach the seeded collectors on both demo accounts (CareVault 11–16, Worksoft 17–24, and every seeded agent in dev ws 3103). The scanner flagged every synthetic line under the seeder’s own finding ids. That overwrote the seeded findings and stripped their demo and scenario markers. Measured before the switch: prod ws 303 had 12,788 such findings, 9,611 of them on 10-19 alone, while the seeder writes about 37 a day. Prod ws 302 had 223 and dev ws 3103 had 305,764. The always-on “PHI in CareVault agent logs” alert stayed ACTIVE on them. In the hour before the switch the prod analyzer wrote 384 of them on ws 303; in the 10 minutes after it, none. All of them were deleted, and the seeder’s findings backfill restored the seeded findings.

Collector-log scanner fixes, analyzer 639e749d … b8ce50fa on dev and prod (with backend 1987c06f / 936dad6e for the bundle fields).

  • Every matching rule flags. preEvaluate(rules, ctx, {collectAll: true}) evaluates every rule and reports every match (BLOCK stays the verdict when any rule blocks); evaluateLine passes it. Before, the logs layer stopped at the first block rule, so a line matching a Block and a Flag rule got one flag (AN-1).
  • Severity is the rule’s own. Each bundle rule carries severity (omitted when null) and the flag uses rule.severity ?? 'medium'; it was always medium (AN-2, RV-8).
  • Plain-English flags carry their policy. The logs bundle’s nlPolicies[] carry policyId, and the flag stores it (AN-3, BE-35); it was null.
  • JUDGE_DISABLED. When the only work for a collector is plain-English rules and AGENT_SCAN_JUDGE_MAX_LINES=0, GET /internal/scan/agent/status answers JUDGE_DISABLED: “The attached policy has only plain-English rules, and the AI judge is off for collector logs in this environment, so these logs have not been examined. Add frameworks or pattern rules.” It used to say EVALUATED_CLEAN. A collector whose pattern rules ran is EVALUATED_CLEAN, no longer “not examined” (AN-4, AN-14).
  • A flag is written once. Flags are persisted with the _bulk create op in chunks of 500 (FLAG_BULK_CHUNK); a per-item 409 means “already flagged” and is not a failure. A rescan used to re-date flaggedAt / @timestamp of every existing flag (AN-5, AN-P8).
  • Triggered scans see the latest policy. scanAgent fetches the logs bundle with {fresh: true}; the status route no longer writes the cache. An edit used to be ignored for up to 60 s (AN-6).
  • Triggers go to worker 0. The analyzer runs ANALYZER_WORKERS cluster workers (6 on dev and prod); a trigger received by any worker is forwarded over cluster IPC to worker 0, where the one debounce and single-flight scheduler lives (back to the sender while worker 0 restarts). Each worker used to debounce on its own (AN-7).
  • Judge sampling is deterministic. Whether a line is in the judge’s sample (sampleRate below
    1. is a hash of the line and the NL rule ids, so a rescan judges the same lines (AN-10; was Math.random).
  • The triggering batch is read. Triggered scans read the window newest first, wait AGENT_SCAN_TRIGGER_DELAY_MS (6000), and re-read once after AGENT_SCAN_TRIGGER_REREAD_MS (5000) when the first read missed the batch (AN-P4, FM-1). Before, a scan triggered by an ingest could miss the lines that triggered it and they stayed unflagged.
  • Scan cost. Up to 4 (workspace, agent) groups scan at once (SCAN_GROUP_CONCURRENCY); the window fetches only message, agentId, workspaceId, serviceName, ingestedAt and counts total hits on page 0 only (AN-P6, AN-P7).
  • CI runs the scanner suites. npm run test:scantrigger, test:scanwindow, test:scantls and test:scanattribution run in the step Collector scanner (trigger, window, TLS, attribution) with if: !cancelled(), so they report even when an earlier gate fails (AN-13, RV-9, RV-14).
  • Kept, with the measurement or the reason:
    • Regex precompilation (AN-P1..P3): construction is 13–18 % of evaluateLine time and memoising it saved 2.0 %; V8’s regexp cache already absorbs the rebuilds.
    • Logs ignore evaluateFrameworks and failMode (AN-8): collector logs are notify-only and Off policies are excluded by the backend.
    • policyText is not judged separately (AN-9): the migrated ai_judge items carry the same text.
    • golden:verify fails 54 of 438 snapshots (C/C++ timing and placement output) under SOURCEFILE_STRICT, identically before the audit (RV-10, open).
    • The runtime image is node:18-alpine while CI tests on Node 20 (RV-16): moving it is a platform decision outside the audit.