Analyzer
Overview
Section titled “Overview”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).
| Setting | Value |
|---|---|
| Technology | Node.js + TypeScript + Express, tree-sitter parsers |
| LLM | Through 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 port | 9000 prod · 9001 dev |
| Public host | analyzer.logrite.ai · dev-analyzer.logrite.ai |
| Repo | Deployd-io/logrite-analyzer (src/server.ts holds the route table) |
| Runs as | A container owned by docker compose on each VM; deployed from the management console |
API routes
Section titled “API routes”From src/server.ts. Everything except the first group requires the internal token.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | none | Liveness, version, commit, parser status |
GET | / | none | Service name and a (partial) endpoint list |
GET | /api-docs, /api-docs.json | none | Swagger UI / OpenAPI spec |
POST | /internal/scan/evaluate, /internal/scan/agent · GET /internal/scan/agent/status | own internal auth | Warden / collector-agent scan evaluation |
GET | /metrics | token | Service metrics |
GET | /analysis-types, /languages | token | Supported analysis rules and languages |
POST | /rules/validate, /rules/propose | token | Dry-run or propose a custom rule before it reaches a scan |
GET | /compliance/rule-suggestions | token | Suggested severity and log level per compliance rule |
POST | /syntax-check | token | Parse-check a file |
POST | /analyze, /analyze/stream | token | Analyse one or more files against explicit rules (the backend’s scan path) |
POST | /analyze/auto | token | Zero-config single-file analyse — see below |
POST | /workspace/register · GET /workspace/:workspaceId | token | Per-scan workspace context |
GET | /jobs, /jobs/:jobId | token | Analysis jobs |
POST | /scan/key-findings, /log-generate/key-findings, /scan/compliance-key-findings, /log-generate/compliance-key-findings | token | Key-findings summaries before and after generation |
POST | /fix-code, /code-fixes | token | Rewrite a file to fix a build error or an issue |
POST | /analyze-exceptions | token | Static 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.
Internal scan routes (2026-10-16)
Section titled “Internal scan routes (2026-10-16)”GET /internal/scan/agent/statusused to treatAGENT_SCAN_ENABLEDas “scanning is on”. That variable only switches the periodic sweep; ingest-triggered scans (triggerAgentScan, viaPOST /internal/scan/agent) run wheneverWARDEN_INTERNAL_TOKENis set. An estate with the sweep off therefore reportedSCANNER_DISABLEDfor logs that had in fact been examined. The response now carriespaths: {periodic, ingestTriggered}, always fetches the logs bundle, and isSCANNER_DISABLEDonly 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 classifiedSCANNER_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-servicesrc/shim/envelope.ts). Prod’s ingest-shim had noANALYZER_API_URLuntil 2026-10-19, so it fell back tohttp://localhost:9000— its own container — and prod had never written a real collector flag for any workspace (every flag inagent-flags-303was seeded,demo:true), while the status route reported the path as enabled. SetANALYZER_API_URL=http://analyzer:9000on prod’s ingest-shim (dev::9001); within two minutes the CareVault care-management collector got its first realhipaa_log_leak_phiflag. Since ai-serviced7cabe54(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 unsetand sends no trigger, and a failed trigger is logged at most once a minute instead of swallowed. Prod’s analyzer also runsAGENT_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}/testand/effective/test) bounds the plain-English judge atEVALUATE_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 carriesjudge: '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 acceptsfeature(the backend sends it); forlogswithAGENT_SCAN_JUDGE_MAX_LINES=0the judge is skipped and the result carriesjudge: 'disabled', so a dry run no longer runs rules the real collector scan does not (AN-4). Otherwisejudgeis'ran'.POST /rules/validate(custom logging-rule dry run) parses the fixture in the project’s language when the backend sends a top-levellanguage(the language of the project’s latest scan, backend42debe8a) and a fixture exists for it; absent or unknown means Java, as before (analyzer970b8027, AN-12).POST /rules/proposegives the model 60 s (PROPOSE_TIMEOUT_MS); past it the route answers 503model_timeout“The language model did not answer within 60 seconds. Retry in a minute.” withretryAfter: 60(analyzer78b3e672, 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.
Health check
Section titled “Health check”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.
Missing mandatory fields
Section titled “Missing mandatory fields”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.
Scan a repository
Section titled “Scan a repository”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.
-
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.
-
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}. -
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) andpollUrl.templatedefaults toGENERAL. AprNumberis 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 literalHEAD. A project you cannot see is a403, one that does not exist a404— never a500. -
Poll
GET /api/v1/scan-runs/latest?projectId=4252untilstateisCOMPLETED(orFAILED, withfailureReason) andscanResultIdis newer thanafterScanId. The demo repository (78 files) took about 15 seconds on dev. -
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).
Reading a scan summary
Section titled “Reading a scan summary”GET /api/v1/scan-results/{id}/summary carries four severity buckets — critical, high,
medium, low — each as a pair:
| Field | Meaning |
|---|---|
<severity>Total | Log statements the template expects at this severity that are missing — the outstanding count |
<severity>Percent | The 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.
Scan a single file
Section titled “Scan a single file”There is no customer route that runs the analyzer on an arbitrary file. Today the closest is the IDE route on the backend:
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}| Field | Required | Notes |
|---|---|---|
projectName | yes (or projectId) | A project of yours that has already been scanned — its latest scan on any branch supplies the template and the checkout |
code | yes | The file’s contents |
fileName | no | A 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 |
projectUrl | no | Narrows 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:
skipReason | Why |
|---|---|
empty | the file is empty |
unsupported-type | the file type holds no method bodies to instrument |
dto | data/DTO file — fields and accessors only |
entry-point | the 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.
Internal routes in detail
Section titled “Internal routes in detail”All of these require X-Analyzer-Token; the backend is the caller.
POST /code-fixes — modes
Section titled “POST /code-fixes — modes”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:
| Field | Effect |
|---|---|
compilerError | The 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" }| Field | Required | Notes |
|---|---|---|
filename | yes | Selects 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 |
content | yes | Raw source text |
template | no | GENERAL (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.
JSX/TSX injection — placement rules
Section titled “JSX/TSX injection — placement rules”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 withUnexpected 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
LanguageDetectoremits"C#";AnalysisEnginenormalizesc#/cs/c-sharp/dotnet→csharpso 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 realName(params)body opener. The previous unanchored regex matched access keywords as substrings of ordinary code (publicwidthoverrides.ContainsKey(…)→ bogus methodContainsKey), mislocating logs. outparameters are dropped from the logged set (extractParameters). Anoutparam is not definitely assigned at entry, so logging it before assignment isCS0269.ref/inare kept.- Auto-generated files are skipped (
isAutoGeneratedFile):*.Designer.cs,AssemblyInfo.cs, and any file with the// <auto-generated>header. WinForms designers carry nousing System;, so an injected bareConsole.WriteLinewould beCS0103; 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 — theirProp = vallines are property assignments, not locals (tracking them logged out-of-scope names likeKey/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#‘snamespacewrapper (type-member level is brace depth 2+, not 1).ensureTimerStartDeclaredCs— injectsvar __logStart = System.Diagnostics.Stopwatch.StartNew();as a method’s first statement when a timing log references__logStart.ElapsedMillisecondswithout it (the C# counterpart ofensureTimerStartDeclared, scope-kind-aware).relocateVarTrackBeforeFirstAssignmentCs— moves a var-track log that landed before the variable’s first assignment to after it (CS0165unassigned local).fixInjectedLogsBeforeElseCs— a log stranded beforeelse/else ifbreaks 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// commentsand keepselselogs inside the new braces (also benefits Java).- Overload / delegate disambiguation —
extractMethodCallSignaturenow parses the entry/exit signature shape>>> Entering Name(a={0},b={1})(placeholders inside the parens) in addition to the audit shapeName(a,b)={},{}, so an overloadedLog(Exception ex)entry/exit log no longer lands in the siblingLog(category,msg).MethodLastLineIndexFinderno longer treatsnew Name(…)constructions (e.g.printDoc.PrintPage += new PrintPageEventHandler(PrintPageEventHandler)) as declarations ofName.
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) | Language | Engine / coverage |
|---|---|---|
.cs, .aspx.cs, .ascx.cs, .cshtml.cs (code-behind) | C# → csharp | CSharpCodeGenerator — first-class, see the C# section above |
.vb, .aspx.vb, .vbhtml | vb | VBNetCodeGenerator |
.fs, .fsx, .fsi | fsharp | FSharpCodeGenerator |
.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):
CodeGeneratorFactoryroutesvb/vbnet/vb.net/visualbasic→VBNetCodeGeneratorandfsharp/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. CodeAnalyzergainedparseVBMethods/parseFSharpMethods(withfindVBMethodEnd/findFSharpMethodEndbounds —Sub/Function…End Sub/End Function; F#let/member) and a language-awareextractParameters: VBname 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 bytests/dotnet-engines.ts).
Backend (Logrite-AI main):
LanguageDetectormaps 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”.BuildUtilgathers.vb/.fs/.fsx/.cshtml/.razor/.vbhtml/.aspx/.ascx/.master/.ashx/.asax, and the C# root-level gather fix (a1e3666) adds depth-0*.csglobs so ASP.NET Web Forms Web Site projects (no.csproj, root-level code files) are no longer reported as “No source files found”.FileUtilbrace 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:
| Stack | Recognized call shapes |
|---|---|
| Microsoft.Extensions.Logging (default ASP.NET Core) | _logger.LogInformation(, LogWarning, LogError, LogDebug, LogTrace, LogCritical; structured _logger.Log(LogLevel.Information, …) |
| Serilog | static 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 + diagnostics | Console.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):
| Folder | Covers | Files |
|---|---|---|
shared/ | cross-language | CodeGeneratorInterface, CodeGeneratorFactory, LoggerDetector, LoggerConfig, languageDetection |
javascript/ | JS + JSX | JavaScriptCodeGenerator, jsTranslator, jsExitLineFinder (tree-sitter exit placement) |
typescript/ | TS + TSX | TypeScriptCodeGenerator |
java/ | Java | JavaCodeGenerator |
python/ | Python | PythonCodeGenerator, pythonTranslator |
csharp/ | C# | CSharpCodeGenerator, csharpTranslator |
vbnet/ | VB.NET | VBNetCodeGenerator, vbTranslator |
fsharp/ | F# | FSharpCodeGenerator, fsharpTranslator |
dotnet/ | .NET family shared | DotNetLoggerDetector, 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.tsbarrels 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 fromCodeAnalyzer, which now delegates).codeParser.tsstill holds an older drifted privatedetectLanguage— 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 toshared/codeAnalysisHelpers, JS post-processing tojavascript/jsPostProcess, per-language recommendation converters to each folder’sconvertRecommendations, Python DB recommendation logic topython/pythonRecommendation, and more (full table insrc/core/languages/README.md). Each engine keeps same-signature delegates, so call sites and behavior are unchanged (verified: suites at baseline + 10-fixture/analyze/autogolden 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:
| Logger | A 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.Logger | log(System.Logger.Level.TRACE, …) |
| Flogger | atFinest().log(…) |
| Go logrus | Tracef |
Go zerolog, global log or a package-level instance | Trace() |
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.
| rule | before | after |
|---|---|---|
soc2_authorization | warn at TRACE, DEBUG, INFO and WARN | the level asked for |
soc2_database_integrity | info at TRACE, DEBUG and INFO | the level asked for |
soc2_incident_response | error at every level | the 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:
- 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. - A logger field, by its initializer:
LoggerFactory.getLogger,LogFactory.getLog,System.getLogger,FluentLogger.forEnclosingClassand similar.Logger.getLoggerexists in JUL, Log4j 1.x and JBoss Logging, so the import or qualified name decides, and an unresolvable one proves nothing. - Anything else, meaning an import alone or nothing at all, is SLF4J. That is what the generator emitted before detection existed.
| Detected API | Call shape (languages/java/jvmLogRenderers.ts) |
|---|---|
| SLF4J, Log4j2 | log.info("a={}", a) — unchanged |
| java.util.logging | LOG.fine("a=" + a); with a throwable LOG.log(java.util.logging.Level.SEVERE, "a=" + a, e) |
| Log4j 1.x, commons-logging, JBoss | log.debug("a=" + a); log.error("a=" + a, e) — the (Object, Throwable) overload |
| System.Logger | LOG.log(System.Logger.Level.WARNING, "a=" + a) |
| Flogger | flog.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, spelledsirupsenorSirupsen;- 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~/, withloggerin 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’sAnchorEmitterregisters a database timer only from the last line it wrote. -
Duration floor. Generators attach
measures_lineto a duration log.capturePlacementHintsmoves it into a side map and deletes the property, so the/analyzeresponse stays byte-identical. The hint’sminKeyis 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,sanitizeJavaDatabaseDurationLinesand 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 thereturn, 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.
enforceDurationFloorsandenforceEntryFirstrun 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.
enforceEntryFirststands down for VB, Kotlin and Python, whose final keys belong to body-key placement, and for the JavaScript family.enforceDurationFloorsno 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. InuserService.jsthe query sits on line 5 at depth 2 and the duration was keyed at line 8, the}of anifblock, 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.
ownerViolationsandownerMovedcount them separately, so the fixture assertions thatviolationsstays at zero keep their meaning.Two things had to be true before it helped:
- A duration log must declare what it times.
jsTimingLogssetmeasures_lineonly on the nested external-call branch, so the ordinary DB duration carried no hint,capturePlacementHintsbuilt nothing and the net could not see it at all. It is now set on both DB duration recommendations, fromfindJsDatabaseCallStartLine. - The terminal-statement guard applies to every owned-elsewhere move, not just the
RETURN_STRICTlanguages. 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 areturn. Without the guard the JS duration moved to the method’s closing brace — unreachable — and the Python one moved betweenreturn Noneand anexceptclause, 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:
durationDeeper9 → 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:
pyGenDbLogsdetects the returned expression, skips the timer, rendersexecuting, and keys the announcement atdbOperation.line— an announcement goes before the call, a duration after it.generatePyDatabaseTimingLogsdeclines the operation outright. Both Python rules reach the same generator, and only the timing path appliesshiftForOffsetOneRule, 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 twofind operation executinglines, 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):
| Measure | Before | After |
|---|---|---|
| Emitted lines | 6535 | 6628 |
entryNotFirst | 66 | 2 |
durationBeforeQuery | 20 | 20 |
durationDeeper | 67 | 46 |
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
typescriptcensus cells wheredurationDeeperwent from 0 to 1. Base3b34ce5failstscon 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
__swafter 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 abovetokenRepository.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:
- an identical statement at an identical key, across rules — two rules independently recommending the same line at the same anchor;
- 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.
The drop ledger’s own coverage
Section titled “The drop ledger’s own coverage”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 − placed | entries the passes removed — the ledger’s unit, a cause is owed for each |
placed − shipped | entries 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 precedentdurationFloorsalready set: a line that disappears without a cause is what the ledger exists to end, and “the object is still inrecommended_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.
The last one was not a remover at all
Section titled “The last one was not a remover at all”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 site | Serves |
|---|---|
bodyKeyPlacement.ts | VB, Kotlin, Python |
returnSafePlacement.ts | C, Lua, Go, Zig |
rustPlacement.ts | Rust |
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 test | Where | What it refuses |
|---|---|---|
getLogStatement().contains("methodReturn →") | LevelTwoDtoBuilder | a variable-tracking shape the pipeline never inserts |
contains("<") && contains(">") | FileUtil.addLine | Java 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 definedEvery 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 0import logging # the import block it was looking forNothing 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.
| Option | Default | What it does | Why that default |
|---|---|---|---|
skip_trivial_methods | off | Withholds 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_hashing | off | Prints 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_fields | on | Appends 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 afalsethat used to be absent cold-starts the cache on every deployed scan for a value meaning the same thing; and forstructured_fieldsabsent andfalsedo not mean the same thing at all, so a helpfully materialisedfalsewould 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: falsehashes asstructured_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 declarationWith 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 + 1is right for an OFFSIDE body, whose last line is a statement; aword-endbody ends on a TOKEN. On an EMPTY VB body the offside walk breaks on the terminator’s own dedent and reports no body, sobodyEndfalls back to the parser’sendLine— theEnd Subline — and+1stepped over it.BC30188: Declaration expected, measured on vb-codeconverterByRefArgument.vbin 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:
| guard | when | judges |
|---|---|---|
dropOrphanTimingScaffolds (analyzer) | after every placement pass | the RECOMMENDATION set |
pruneOrphanedTimerStarts (backend) | before placement | the INSERTION set |
removeOrphanedTimerDeclarations (backend) | after the write | what 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.
Recognising the timer is the hard half
Section titled “Recognising the timer is the hard half”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 rulePlacementfalls 0.667 → 0.500, and that is the metric being corrected.external_service_calls_response_timeswas 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 spotruleCoveragehas, seen from the other side.
A C# arrow member has no brace to stop at
Section titled “A C# arrow member has no brace to stop at”=> 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:
| member | extent computed | extent it has |
|---|---|---|
ToDeviceStatus (arrow on the next line) | 24 – 40 | 24 – 25 |
Describe (arrow on the declaration line) | 29 – 40 | 29 |
Combine (a real body) | 34 – 40 | 34 – 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 ToDeviceStatusoverrides 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.
Block comments and JavaScript methods
Section titled “Block comments and JavaScript methods”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-nestedfor Rust, Swift, Kotlin, Scala and Dart, where comments nest; andpowershell. JS and TS take comment ranges from Babel’sast.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
functionor=>; - 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, forget,post,put,delete,patch,head,optionsandall. Arrow handlers and wrappers such asasyncHandler(...)are included:app.get('/users', function (req, res) {…})becomesGET_users;router.post('/items/:id', async (req, res) => {…})becomesPOST_items_id;- a path of
/becomesGET_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 olduse()name was an accident of the shorthand regex, which never matched arrow handlers at all. This matches the other languages: Gofuncliterals, 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 methodrender, notfunction. A quoted key must be an identifier, so'text/plain; charset=utf-8': functiongets no name. A function expression assigned to a property ($.fn.showElement = function () {) is named by that property. This only renames the oldfunction (…) {shape and detects nothing new.
Known and pre-existing follow-ups:
- The by-name lookups in
coordinateLineNumbersanddeduplicateAcrossAllMethodsstill lose one-liner exit logs when several classes share a method name (honohandler.ts). stringFence.tscounts backticks naively.- Scala variable tracking can find a declaration inside a comment.
- A C++
#includeanchor can follow an#includethat sits inside a comment.
Quality gates for generation changes
Section titled “Quality gates for generation changes”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:
| Script | Proves |
|---|---|
test:loghonoured | Every template renders the rule’s level, TRACE included |
test:levelratchet | No new hard-coded level anywhere in src/ (see Log levels) |
test:bankjavalevels | The live BankJava and JobSearch rule configurations on their real files: DB timing at WARN, every JobSearch line at TRACE |
verify:levels | REAL 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:loggerapi | JVM API detection and renderers, logger detection across languages, logger API and brackets |
test:entryfirst | Entry, 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:bankjavajavac | REAL javac and Lombok on the instrumented BankJava AccountServiceImpl |
test:commentcallback | The JobSearch #677 comment and callback defects, on the original jobs.js and candidates.js |
test:expressroutes | Route 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:jvmloggersinstruments the 11 fixtures undertests/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
passwordvalue 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:gologgersrunsgo buildon 5 fixture packages: std, logrus imported aslog, zerolog global, a zerolog instance, and zap.- REAL: the Go toolchain and standard library.
- STUB: logrus, zerolog and zap, as
replacemodules built withGOPROXY=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.
- 6 committed sets:
census.ts <baseline> <candidate>exits 1 in two cases:- any
<language>/<ruleSet>cell emits fewer physical lines than the baseline; - any invariant count rises.
- any
- 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 carriesreplaces_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.
keyInsideCommentandphantomMethodare reported and never gated.
- Never record into the committed snapshots. Record the base into a shadow root and point
GOLDEN_SNAPSHOT_ROOTat 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, integerfromandto, and areason. - With
invariant, a rise is excused only while the baseline readsfromand the candidate reads at mostto. - Without
invariant, a loss of emitted lines is excused only while the baseline readsfromand the candidate reads at leastto. - 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},durationDeeper0 → 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:
tsc0; file-size ratchet 0;lint:cyclesclean;lint:perfidentical to base (raw-line-split85/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:javac11 clean / 1 broken (EnterpriseService), with the same errors as base.verify-log-level401 → 412 of 440 cells honour the requested level; no cell became less exact.verify-language-matrixbroken cells 3 → 2, none newly broken.verify:jvmloggers449/0;verify:gologgers107/0.- Census exit 0, with 4 of 4 intended deltas still applying against the new base; method census exit 0.
golden:verifydiffers in 111 cases (base: 104, because the committed snapshots predatedev’s level prefixes). The 7 new cases are all fixes: a stray)removed from duration text,log.replaced by the class’s ownlogger., a duration keyed after its query, and a TypeScript entry log moved out of anif.
Analysis rules
Section titled “Analysis rules”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.
Which citation gets emitted
Section titled “Which citation gets emitted”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 complianceRef | Emitted | Why |
|---|---|---|
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-12 | nothing | three controls — quoting one would be a truncation |
ISO/IEC 27001:2022 Annex A.8.15 / A.8.16 | nothing | two controls |
16 CFR 314.4(a)-(b) | nothing | quoting …(a) would silently drop -(b) |
NYC Local Law 144 §20-871(b) | nothing | §20 is not a section of that law |
SOX ITGC — computer operations | nothing | the 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.
Running, building and deploying
Section titled “Running, building and deploying”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.
# 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 containerdocker logs -f "$(docker ps --format '{{.Names}}' | grep ^c12g1iwg)"Environment variables
Section titled “Environment variables”Non-secret names only; values live in the compose environment on each VM.
| Variable | Purpose |
|---|---|
PORT | Listen port — 9000 prod, 9001 dev |
ANALYZER_INTERNAL_TOKEN | Required. Shared secret the backend sends as X-Analyzer-Token; the service refuses to start without it |
ANALYZER_ALLOW_NO_AUTH | true lets it start without the token — local runs only |
AI_PROVIDER, LLM_MODEL | LLM provider and model (neuralwatt, default deepseek-v4.1-flash) |
NEURALWATT_API_KEY / GROQ_API_KEY / LLM_API_KEY | Provider credential for the provider selected |
OPENSEARCH_URL, OPENSEARCH_USERNAME, OPENSEARCH_PASSWORD | Cluster used by the agent-scan path |
WARDEN_BACKEND_URL, WARDEN_INTERNAL_TOKEN | Warden control plane, for the /internal/scan/* routes |
METERING_BACKEND_URL, METERING_INGEST_TOKEN | LLM usage metering (fail-open) |
ANALYZE_MAX_INFLIGHT | Admission-control limit on the analysis routes; unset = no limit |
AGENT_SCAN_ENABLED | Periodic 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:
| table | keyed on | change it to affect |
|---|---|---|
LEVEL_BY_EVENT_CLASS | the rule’s event class | every coverage rule of that class |
LEVEL_BY_LEAK_SEVERITY | the rule’s severity | every leak rule of that severity |
RULE_SUGGESTION_OVERRIDES | one rule id | that 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.
Where a change travels
Section titled “Where a change travels”1. analyzer ruleSuggestionTable.ts ← the only edit2. 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 level4. frontend utils/ruleSuggestions.js → RuleToggle → RuleSuggestionNoteNo 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.
SOC2 CC7.2 / CC7.3 messages (2026-10-09)
Section titled “SOC2 CC7.2 / CC7.3 messages (2026-10-09)”- 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
logno longer matchescatalogorlogin, andgrantno longer matchesemigrant. 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).
Audit changes (2026-10-10)
Section titled “Audit changes (2026-10-10)”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_afteron[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_DOCSis gone), withtrack_total_hits. When more remain, the outcome isPARTIAL(counted as examined) and a warning is logged instead of silently stopping at 2000 documents. - Bulk partial failures. When an OpenSearch
_bulkreply haserrors: true, only the 2xx items count as indexed and the first error’s type and reason are logged. format=hierarchicalreturns real values. The package comes from the file’spackageline (Java/Kotlin) or is omitted;abs_file_pathis 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, Pythonlogger = logging.getLogger(__name__), omitted for JS/TS and others). The fixed demo constants are gone.logAddedmatches 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.
/healthcommitis 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).
| Variable | Default | Meaning |
|---|---|---|
OPENSEARCH_VERIFY_CERTS | false | Verify the OpenSearch certificate |
AGENT_SCAN_ENABLED | unset | The periodic scan. Unset on both estates |
AGENT_SCAN_TRIGGER_WINDOW_MIN | 10 | Window, 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_MS | 6000 | Debounce before a triggered scan reads the window (2026-10-21; was about 4 s) |
AGENT_SCAN_TRIGGER_REREAD_MS | 5000 | When 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_LINES | 80 | Unique lines per scan, per agent, sent to the NL judge. 0 turns the judge off; the deterministic layers still run |
(constant) LINE_JUDGE_TIMEOUT_MS | 12 000 | Bound 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_WORKSPACES | unset | Comma 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_MS | 60 000 | How 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_MAX | 500 | Bundle 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.
Rules & Policies audit (2026-10-21)
Section titled “Rules & Policies audit (2026-10-21)”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);evaluateLinepasses it. Before, the logs layer stopped at the firstblockrule, 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 usesrule.severity ?? 'medium'; it was alwaysmedium(AN-2, RV-8). - Plain-English flags carry their policy. The logs bundle’s
nlPolicies[]carrypolicyId, 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 andAGENT_SCAN_JUDGE_MAX_LINES=0,GET /internal/scan/agent/statusanswersJUDGE_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 sayEVALUATED_CLEAN. A collector whose pattern rules ran isEVALUATED_CLEAN, no longer “not examined” (AN-4, AN-14).- A flag is written once. Flags are persisted with the
_bulkcreateop in chunks of 500 (FLAG_BULK_CHUNK); a per-item 409 means “already flagged” and is not a failure. A rescan used to re-dateflaggedAt/@timestampof every existing flag (AN-5, AN-P8). - Triggered scans see the latest policy.
scanAgentfetches 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_WORKERScluster 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 (
sampleRatebelow- is a hash of the line and the NL rule ids, so a rescan judges the same lines (AN-10; was
Math.random).
- is a hash of the line and the NL rule ids, so a rescan judges the same lines (AN-10; was
- The triggering batch is read. Triggered scans read the window newest first, wait
AGENT_SCAN_TRIGGER_DELAY_MS(6000), and re-read once afterAGENT_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 onlymessage, agentId, workspaceId, serviceName, ingestedAtand counts total hits on page 0 only (AN-P6, AN-P7). - CI runs the scanner suites.
npm run test:scantrigger,test:scanwindow,test:scantlsandtest:scanattributionrun in the step Collector scanner (trigger, window, TLS, attribution) withif: !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
evaluateLinetime and memoising it saved 2.0 %; V8’s regexp cache already absorbs the rebuilds. - Logs ignore
evaluateFrameworksandfailMode(AN-8): collector logs are notify-only and Off policies are excluded by the backend. policyTextis not judged separately (AN-9): the migratedai_judgeitems carry the same text.golden:verifyfails 54 of 438 snapshots (C/C++ timing and placement output) underSOURCEFILE_STRICT, identically before the audit (RV-10, open).- The runtime image is
node:18-alpinewhile CI tests on Node 20 (RV-16): moving it is a platform decision outside the audit.
- Regex precompilation (AN-P1..P3): construction is 13–18 % of