diff --git a/CLAUDE.md b/CLAUDE.md index e8ae38ac..f6543f62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Dev mode: the Vite server proxies `/api` and `/ws` to the backend on 7580. The b ``` ingest adapter → line splitter → multi-line aggregator → format parsers → normalizer - (chunks) (lines) (entries w/ stack traces) (monolog|clf|jsonl|raw) + (chunks) (lines) (entries w/ stack traces) (monolog|clf|jsonl|bitnami|raw) ↓ ring buffer (assigns monotonic id) ↓ diff --git a/docs/decisions.md b/docs/decisions.md index b07095c9..63af834b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -45,3 +45,7 @@ Both `traceriver` and `trace-river` were unclaimed on npm as of 2026-07-19. Ship ## D11 — Project association prefers exact path signals over name heuristics (2026-07-22) Phase 2 associated containers with the current project by name: `com.docker.compose.project` vs. a name derived from cwd (compose-file `name:`, else normalized basename). Real stacks break this — Lando derives `streetbites` from `street_bites` and keeps its compose files in `~/.lando/compose/`, so nothing matched (phase 5, scenario S1). Tools stamp the host project's absolute path onto containers (`io.lando.root`, `com.docker.compose.project.working_dir`); comparing that against cwd is deterministic where name normalization is guesswork. Matching order is therefore path label → compose-file `name:` → normalized basename, strictly short-circuiting: an applicable path signal decides even when negative — a weaker name heuristic must not override an exact path that says "no". Forward direction only (label equals or is an ancestor of cwd); reverse/monorepo matching was explicitly deferred by the product owner pending a real captured scenario, because a broad cwd (e.g. `~/projects`) would associate every container beneath it. Details in [architecture.md](architecture.md#docker-project-association-phases-2--5). + +## D12 — Recognize Bitnami container-library log lines as a first-class format (2026-07-23) + +Bitnami images (Redis, MariaDB, nginx, PostgreSQL, and the reverb/nginx/cache containers in the street_bites stack) emit their entrypoint/setup logs to **stderr** in a fixed shape — ` ==> ` — where the app states its own level (INFO/DEBUG/WARN/ERROR). No built-in parser matched it, so the source locked onto `raw`, which ignores that declarative level; the docker adapter's stderr WARN floor then lifted every self-declared INFO/DEBUG line to WARN, and occasional keyword hits in echoed config text produced false ERROR (issue #8). A narrow `bitnami` parser, positioned just ahead of `raw` (it only fires on the `==>` marker other parsers never claim), extracts the declared level; because the level is then known, the floor — which fills UNKNOWN only — no longer overrides it. The bare wall-clock time carries no date and is discarded in favor of Docker's per-line timestamp. Separately, the `raw` parser now classifies pure-decoration lines (banner ASCII art of box/block glyphs, or a rule of ≥4 repeated separator chars) as DEBUG so startup splashes sink below the default view instead of reading as UNKNOWN/WARN noise — matched only when the whole line is decoration, so a comment like `# Based on …` is never caught. diff --git a/docs/log-schema.md b/docs/log-schema.md index b4b372db..6c54cc9c 100644 --- a/docs/log-schema.md +++ b/docs/log-schema.md @@ -100,7 +100,8 @@ Initial chain, in order: | `monolog` | `[2026-07-19 15:31:15] production.ERROR: message {ctx} []` | Laravel/Symfony/Monolog. Channel + level captured; trailing JSON blobs parsed into `context`. | | `clf` | Nginx/Apache access + error formats | Access lines: method/path/status into `context`, level derived from status (5xx→ERROR, 4xx→WARN). Error-log format handled separately (`[error]`, `[warn]` markers). | | `jsonl` | Line parses as a JSON object | Maps common key aliases: `level`/`severity`/`lvl`, `msg`/`message`, `time`/`ts`/`timestamp`/`@timestamp`. Covers pino, winston, bunyan, zap, logrus. | -| `raw` | Always (fallback) | Level inferred by keyword scan (`error`, `exception`, `fatal`, `warn` as whole words); timestamp = arrival time. Never fails. | +| `bitnami` | `postgresql 15:31:15.42 INFO ==> Starting…` | Bitnami container-library bootstrap lines (` ==>`). Extracts the self-declared level so it isn't left UNKNOWN and floored to WARN on stderr. Bare wall-clock time is discarded in favor of Docker's per-line timestamp. | +| `raw` | Always (fallback) | Level inferred by keyword scan (`error`, `exception`, `fatal`, `warn` as whole words); pure-decoration lines (banner ASCII art / separator rules) are classified DEBUG so they don't read as noise; timestamp = arrival time. Never fails. | **Detection with per-source stickiness.** Running every parser against every line is wasted work and causes flapping. Instead: diff --git a/package-lock.json b/package-lock.json index adf3b9d1..55f5b4b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "traceriver", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "traceriver", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "@fastify/static": "^8.0.0", diff --git a/package.json b/package.json index bc71ed59..25820772 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "traceriver", - "version": "0.2.0", + "version": "0.3.0", "description": "Local log console — consolidate Docker, framework, and file logs into one unified stream and surface the errors. Under active development.", "keywords": [ "logs", diff --git a/src/CLAUDE.md b/src/CLAUDE.md index f3042924..c49796f5 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -18,7 +18,7 @@ Non-obvious invariants (full spec: `docs/log-schema.md`): - **ANSI escapes are stripped in the line splitter, before any regex sees a line** — colored dev output otherwise breaks every format matcher. Partial-line buffering: chunks never align with newlines; hold the tail fragment until the next chunk, flush on stream end or 2 s idle. - **Aggregation**: a line not matching the source's established `entryStart` pattern continues the previous entry's `body`. Cap: 500 lines / 256 KB per entry; overflow starts a new entry flagged `context.truncated`. 2 s idle flush emits a pending aggregate. -- **Detection is sticky per source**: chain order `monolog → clf → jsonl → raw`; a parser locks after scoring ≥ 0.8 on 3 of the first ~20 entries; 10 consecutive raw-fallbacks reset the lock; uploads detect on the first 50 lines then commit for the whole file. User-defined regex parsers from `traceriver.json` insert at the head of the chain. +- **Detection is sticky per source**: chain order `monolog → clf → jsonl → bitnami → raw`; a parser locks after scoring ≥ 0.8 on 3 of the first ~20 entries; 10 consecutive raw-fallbacks reset the lock; uploads detect on the first 50 lines then commit for the whole file. User-defined regex parsers from `traceriver.json` insert at the head of the chain. - **Normalization**: levels map to the 6-value enum (mapping table in `docs/log-schema.md` — includes pino numeric levels and HTTP-status derivation); timestamps → epoch ms UTC, zone-less timestamps assumed host-local, unparseable → arrival time with `rawTimestamp` preserved. `raw` parser never fails. **Adding a format parser**: implement the `FormatParser` interface (`formats/types.ts` — `name`, `entryStart`, `score()`, `parse()`), register it in `formats/index.ts` at the right chain position, and add a real-world fixture + golden test + it must pass the chunk-boundary fuzz test (see `test/CLAUDE.md`). diff --git a/src/parsers/formats/bitnami.ts b/src/parsers/formats/bitnami.ts new file mode 100644 index 00000000..d34ece5a --- /dev/null +++ b/src/parsers/formats/bitnami.ts @@ -0,0 +1,46 @@ +import type { AggregatedEntry, FormatParser, ParsedFields } from "./types.js"; + +/** + * Bitnami container-library log line, emitted by the `liblog.sh` helper every + * Bitnami image's entrypoint/setup scripts use: + * + * ==> + * e.g. postgresql 15:31:15.42 INFO ==> Starting PostgreSQL setup + * + * The layout is `printf "%s %s %-5.5s ==> %s"` — module name, a bare wall-clock + * time (no date), a 5-width left-justified level, the literal `==>` marker, and + * the message. These scripts write to **stderr**, so without recognizing the + * self-declared level here every line is left UNKNOWN and then floored up to + * WARN by the docker adapter's stderr level floor — turning self-declared + * INFO/DEBUG bootstrap chatter into a wall of spurious warnings (issue #8). + * + * The embedded time carries no date and cannot be trusted for ordering, so it + * is deliberately discarded (`rawTimestamp: null`, `timestampHint: "none"`) — + * the pipeline falls back to Docker's per-line RFC3339 timestamp instead. + */ +const BITNAMI_RE = + /^(?[a-z][a-z0-9._-]*)\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+(?DEBUG|INFO|WARN|ERROR)\s+==>\s?(?.*)$/; + +export const bitnamiParser: FormatParser = { + name: "bitnami", + entryStart: BITNAMI_RE, + timestampHint: "none", + + score(line: string): number { + return BITNAMI_RE.test(line) ? 0.9 : 0; + }, + + parse(entry: AggregatedEntry): ParsedFields { + const firstLine = entry.lines[0] ?? ""; + const match = firstLine.match(BITNAMI_RE); + if (!match?.groups) { + return { level: null, rawTimestamp: null, message: firstLine, context: null }; + } + return { + level: match.groups.level, + rawTimestamp: null, + message: match.groups.message, + context: null, + }; + }, +}; diff --git a/src/parsers/formats/index.ts b/src/parsers/formats/index.ts index a85c34e6..feea63d1 100644 --- a/src/parsers/formats/index.ts +++ b/src/parsers/formats/index.ts @@ -2,14 +2,25 @@ import type { FormatParser } from "./types.js"; import { monologParser } from "./monolog.js"; import { clfParser } from "./clf.js"; import { jsonlParser } from "./jsonl.js"; +import { bitnamiParser } from "./bitnami.js"; import { rawParser } from "./raw.js"; export type { FormatParser, AggregatedEntry, ParsedFields } from "./types.js"; -export { monologParser, clfParser, jsonlParser, rawParser }; +export { monologParser, clfParser, jsonlParser, bitnamiParser, rawParser }; /** Built-in chain, in order. Custom user parsers (traceriver.json) are - * inserted at the head by src/parsers/pipeline.ts when configured. */ -export const BUILTIN_PARSER_CHAIN: FormatParser[] = [monologParser, clfParser, jsonlParser, rawParser]; + * inserted at the head by src/parsers/pipeline.ts when configured. `bitnami` + * sits just ahead of the `raw` fallback: it's a narrow, high-signal match + * (the `==>` marker) that only ever fires on genuine Bitnami lines the other + * parsers don't claim, rescuing their self-declared level before `raw` would + * drop it (issue #8). */ +export const BUILTIN_PARSER_CHAIN: FormatParser[] = [ + monologParser, + clfParser, + jsonlParser, + bitnamiParser, + rawParser, +]; /** * Built-in parsers keyed by name, for a `traceriver.json` `watch` entry's @@ -23,5 +34,6 @@ export const PARSER_BY_NAME: Record = { monolog: monologParser, clf: clfParser, jsonl: jsonlParser, + bitnami: bitnamiParser, raw: rawParser, }; diff --git a/src/parsers/formats/raw.ts b/src/parsers/formats/raw.ts index 0feb29f0..8cdc36a5 100644 --- a/src/parsers/formats/raw.ts +++ b/src/parsers/formats/raw.ts @@ -16,6 +16,41 @@ function keywordLevel(line: string): string | null { return null; } +// Any ASCII letter or digit — its presence means the line carries readable +// text and is therefore never decoration. Tested first as a cheap, allocation- +// free reject so ordinary log lines (the overwhelming majority) exit before the +// stripped-copy path below (keeps the `raw` parser's per-line hot path clean). +const HAS_ALNUM_RE = /[A-Za-z0-9]/; +// Box-drawing (U+2500–U+257F) and block-element (U+2580–U+259F) glyphs, with +// interior whitespace allowed — the alphabet startup banners/logos are drawn +// from (e.g. the `███` Redis/MariaDB splash). +const BOX_BLOCK_LINE_RE = /^[\s─-▟]+$/; +// A horizontal rule: 4+ repetitions of a single ASCII separator glyph (after +// interior whitespace is collapsed out). +const ASCII_RULE_RE = /^([=\-_~*#+])\1{3,}$/; + +/** + * A pure-decoration line — startup-banner ASCII art or a separator rule, with + * no readable message. Bitnami/Redis/MariaDB images emit a lot of these on + * their startup streams; left alone they surface as their own UNKNOWN rows + * (or, on a stderr stream carrying a WARN floor, as spurious WARN rows) and + * read as noise/warnings (issue #8). We only match when *every* non-whitespace + * character is decoration — a line with any letter or digit (e.g. a comment + * like `# Based on https://…`) is never treated as decoration. + */ +function isDecorationLine(line: string): boolean { + // Fast, non-allocating reject for the common case (real text present). + if (HAS_ALNUM_RE.test(line)) return false; + // Box/block art: match directly (whitespace permitted), no copy needed. The + // `\S` guard requires at least one non-space glyph so a blank/whitespace line + // isn't treated as decoration. + if (/\S/.test(line) && BOX_BLOCK_LINE_RE.test(line)) return true; + // Separator rule: only now (rare — the line is punctuation-only) collapse + // interior whitespace and require 4+ of a single rule glyph. + const stripped = line.replace(/\s+/g, ""); + return stripped.length >= 4 && ASCII_RULE_RE.test(stripped); +} + /** * The `raw` fallback parser. Always matches — it's the last link in the * chain — but its `score()` deliberately stays below the 0.8 auto-lock @@ -33,8 +68,12 @@ export const rawParser: FormatParser = { parse(entry: AggregatedEntry): ParsedFields { const firstLine = entry.lines[0] ?? ""; + // A decoration line self-classifies as DEBUG so it sinks below the default + // view instead of surfacing as an UNKNOWN/floored-WARN noise row — and, + // being non-UNKNOWN, it is never lifted by a stream's level floor. + const level = isDecorationLine(firstLine) ? "DEBUG" : keywordLevel(firstLine); return { - level: keywordLevel(firstLine), + level, rawTimestamp: null, message: firstLine, context: null, diff --git a/src/parsers/pipeline.ts b/src/parsers/pipeline.ts index 00a1c1e5..8fe94988 100644 --- a/src/parsers/pipeline.ts +++ b/src/parsers/pipeline.ts @@ -148,15 +148,18 @@ export class SourcePipeline extends EventEmitter { // Never withhold a live entry while detection is still open (defect // 002-phase-2-docker-1, spec 002 criterion 5: subscribing must show // entries "within one broadcast interval"). Emit immediately, - // provisionally tagged with whatever parser has already earned an - // early lock this call (usually none yet, so `rawParser`) — the - // sticky-per-source-parser guarantee (docs/log-schema.md) still - // holds for every entry from the point detection actually commits - // onward; only these first few, already-visible entries keep + // provisionally tagged with an already-earned early lock, else the + // best parser that strongly matches *this* line, else `rawParser` — + // so a line that unmistakably looks like a format (e.g. a Bitnami + // line before the source has locked, issue #8) already carries its + // real level instead of an UNKNOWN that a stderr floor then lifts to + // WARN. The sticky-per-source-parser guarantee (docs/log-schema.md) + // still holds for every entry from the point detection actually + // commits onward; only these first few, already-visible entries keep // whatever provisional tag they were shown with rather than being // retroactively re-tagged. this.liveEntriesScored += 1; - const provisional = this.checkEarlyLock() ?? rawParser; + const provisional = this.checkEarlyLock() ?? this.bestScoringParser(entry.lines[0] ?? "") ?? rawParser; this.emit("entries", [this.buildLog(entry, provisional)]); } else { this.bufferedDetectionEntries.push(entry); @@ -195,6 +198,17 @@ export class SourcePipeline extends EventEmitter { return null; } + /** Highest-priority parser (chain order) that scores at/above the lock + * threshold for a single line — used only to give a pre-lock live entry a + * better provisional tag than `raw`. Returns null when nothing matches + * strongly (the ordinary unstructured-line case). */ + private bestScoringParser(line: string): FormatParser | null { + for (const parser of this.chain) { + if (safeScore(parser, line) >= LOCK_SCORE_THRESHOLD) return parser; + } + return null; + } + private commitDetection(): void { this.detecting = false; let winner = this.checkEarlyLock(); diff --git a/test/discovery/rotation-truncation.test.ts b/test/discovery/rotation-truncation.test.ts index 3d683f65..75d5ac14 100644 --- a/test/discovery/rotation-truncation.test.ts +++ b/test/discovery/rotation-truncation.test.ts @@ -104,14 +104,16 @@ describe("Criterion 4 — truncation doesn't break the tail", () => { const afterLines = all.filter((e) => e.message.includes("after truncate")); // Exactly one copy, no duplication/garbling from the truncate+reset — // this is genuinely the *first* entry this source's pipeline has ever - // seen (the pre-truncation content was skipped by the EOF-start - // attach), so it's still within the live-detection window and - // provisionally tagged `raw` (docs/log-schema.md § detection - // stickiness — a fresh source always starts "detecting"; a single - // entry can't yet earn a lock, which requires 3 of the first ~20 to - // score ≥0.8). `message` for the raw parser is the whole line verbatim. + // seen (the pre-truncation content was skipped by the EOF-start attach), + // so it's still within the live-detection window and a single entry + // can't yet earn a lock (which requires 3 of the first ~20 to score + // ≥0.8). It is nonetheless emitted provisionally tagged with the parser + // that strongly matches *this* line — monolog here (issue #8) — rather + // than the `raw` fallback, so it already renders with its parsed message + // and level instead of the whole line verbatim. expect(afterLines).toHaveLength(1); - expect(afterLines[0].message).toBe("[2026-07-19 09:05:00] local.INFO: after truncate {} []"); + expect(afterLines[0].message).toBe("after truncate"); + expect(afterLines[0].level).toBe("INFO"); const res = await fetch(`${ts!.baseUrl}/api/sources`, { headers: { Authorization: `Bearer ${ts!.token}` }, diff --git a/test/fixtures/bitnami.log b/test/fixtures/bitnami.log new file mode 100644 index 00000000..adc7fa80 --- /dev/null +++ b/test/fixtures/bitnami.log @@ -0,0 +1,8 @@ +redis 04:03:40.05 INFO ==> ** Starting Redis setup ** +userperms 04:03:40.12 DEBUG ==> Ensuring expected directories/files exist... +userperms 04:03:40.35 INFO ==> Remapping ownership to handle docker volume sharing. +loadkeys 04:03:40.48 DEBUG ==> Checking whether /user/.ssh/known_hosts.old is a private key +landonginx 04:03:40.70 DEBUG ==> # Based on https://www.nginx.com/resources/wiki/start/topics/examples/full/ +redis 04:03:40.88 WARN ==> A password is not set for the default Redis user. +redis 04:03:41.02 ERROR ==> Could not bind to requested port, retrying. +redis 04:03:41.20 INFO ==> ** Redis setup finished! ** diff --git a/test/parsers/bitnami.test.ts b/test/parsers/bitnami.test.ts new file mode 100644 index 00000000..9b464116 --- /dev/null +++ b/test/parsers/bitnami.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { bitnamiParser } from "../../src/parsers/formats/bitnami.js"; +import type { AggregatedEntry } from "../../src/parsers/formats/types.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function readFixtureLines(name: string): string[] { + const raw = readFileSync(join(__dirname, "..", "fixtures", name), "utf8"); + return raw.split("\n").filter((l) => l.length > 0); +} + +function entry(line: string): AggregatedEntry { + return { lines: [line], raw: line, truncated: false }; +} + +describe("bitnamiParser — golden fixture (test/fixtures/bitnami.log)", () => { + const lines = readFixtureLines("bitnami.log"); + + it("scores every bitnami line above the 0.8 auto-lock threshold", () => { + for (const line of lines) { + expect(bitnamiParser.score(line)).toBeGreaterThanOrEqual(0.8); + } + }); + + it("extracts the self-declared level from each line", () => { + const levels = lines.map((l) => bitnamiParser.parse(entry(l)).level); + expect(levels).toEqual(["INFO", "DEBUG", "INFO", "DEBUG", "DEBUG", "WARN", "ERROR", "INFO"]); + }); + + it("strips the module/time/level/marker prefix, leaving only the message", () => { + const fields = bitnamiParser.parse(entry(lines[2])); + expect(fields.message).toBe("Remapping ownership to handle docker volume sharing."); + }); + + it("discards the dateless wall-clock time (Docker's per-line timestamp wins downstream)", () => { + const fields = bitnamiParser.parse(entry(lines[0])); + expect(fields.rawTimestamp).toBeNull(); + expect(fields.context).toBeNull(); + }); + + it("does not claim lines the other parsers own", () => { + expect(bitnamiParser.score("[2026-07-19 15:31:15] production.ERROR: boom {} []")).toBe(0); + expect(bitnamiParser.score('{"level":"info","msg":"hi"}')).toBe(0); + expect(bitnamiParser.score("Starting worker process")).toBe(0); + // A plausible-looking near-miss without the `==>` marker must not match. + expect(bitnamiParser.score("redis 04:03:40.05 INFO starting up")).toBe(0); + }); +}); diff --git a/test/parsers/chunk-fuzz.test.ts b/test/parsers/chunk-fuzz.test.ts index f81dfad5..3d0595ea 100644 --- a/test/parsers/chunk-fuzz.test.ts +++ b/test/parsers/chunk-fuzz.test.ts @@ -21,6 +21,7 @@ const FIXTURES = [ "pino.jsonl", "raw.log", "nasty.log", + "bitnami.log", ]; const TRIALS_PER_FIXTURE = 15; diff --git a/test/parsers/pipeline-golden.test.ts b/test/parsers/pipeline-golden.test.ts index b1537539..f8c6d715 100644 --- a/test/parsers/pipeline-golden.test.ts +++ b/test/parsers/pipeline-golden.test.ts @@ -107,6 +107,46 @@ describe("SourcePipeline golden — raw.log (fallback)", () => { }); }); +/** Feeds a fixture line-by-line through a fresh *live* SourcePipeline — the + * path docker/tail sources take — optionally under a stderr-style level floor. */ +async function runLivePipeline( + fixtureName: string, + opts: { levelFloor?: "WARN" } = {}, +): Promise { + const text = readFileSync(fixturePath(fixtureName), "utf8"); + const pipeline = new SourcePipeline({ sourceId: `docker:${fixtureName}`, mode: "live", ...opts }); + const collected: TraceRiverLogInput[] = []; + pipeline.on("entries", (entries) => collected.push(...entries)); + for (const line of text.split("\n").filter((l) => l.length > 0)) pipeline.feedLine(line); + pipeline.end(); + return collected; +} + +describe("SourcePipeline golden — bitnami.log under a docker stderr WARN floor (issue #8)", () => { + it("honors each line's self-declared level instead of flooring INFO/DEBUG up to WARN", async () => { + const entries = await runLivePipeline("bitnami.log", { levelFloor: "WARN" }); + expect(entries).toHaveLength(8); + // Every line's own level survives — the WARN floor fills only UNKNOWN, and + // the bitnami parser leaves nothing UNKNOWN. Includes the first two lines, + // emitted provisionally before the source locks (per-line best-match tag). + expect(entries.map((e) => e.level)).toEqual([ + "INFO", + "DEBUG", + "INFO", + "DEBUG", + "DEBUG", + "WARN", + "ERROR", + "INFO", + ]); + // The module/time/level/`==>` prefix is stripped from the rendered message. + expect(entries[2].message).toBe("Remapping ownership to handle docker volume sharing."); + // The dateless wall-clock time is discarded; with no docker per-line + // timestamp supplied here, entries fall back to arrival time. + expect(entries.every((e) => e.timestamp === FIXED_NOW)).toBe(true); + }); +}); + describe("SourcePipeline golden — nasty.log (ANSI, mixed formats, stack trace)", () => { it("strips ANSI codes before parsing and never crashes on mixed-format content", async () => { const entries = await runPipeline("nasty.log"); diff --git a/test/parsers/raw.test.ts b/test/parsers/raw.test.ts index 767018e9..dbf17725 100644 --- a/test/parsers/raw.test.ts +++ b/test/parsers/raw.test.ts @@ -48,4 +48,22 @@ describe("rawParser — golden fixture (test/fixtures/raw.log)", () => { const noKeyword = rawParser.parse(entry("a terroir wine review with no matching keyword")); expect(noKeyword.level).toBeNull(); }); + + it("classifies pure-decoration lines as DEBUG so banners/rules sink below the default view (issue #8)", () => { + // Block-glyph startup banner (Redis/MariaDB splash art). + expect(rawParser.parse(entry("███████ ██ ██ ██ ████ ██████ ██████")).level).toBe("DEBUG"); + // Horizontal separator rules of a single repeated character. + expect(rawParser.parse(entry("===============================================")).level).toBe("DEBUG"); + expect(rawParser.parse(entry("-----------------------------------------------")).level).toBe("DEBUG"); + expect(rawParser.parse(entry("###########")).level).toBe("DEBUG"); + }); + + it("never treats readable text as decoration, even when it opens with a rule glyph", () => { + // A comment or config line that merely starts with `#`/`=` still has words. + expect(rawParser.parse(entry("# Based on https://www.nginx.com/resources/wiki/")).level).toBeNull(); + expect(rawParser.parse(entry("=== Starting server ===")).level).toBeNull(); + // Too short / mixed to be a rule. + expect(rawParser.parse(entry("---")).level).toBeNull(); + expect(rawParser.parse(entry("=-=-=-=")).level).toBeNull(); + }); });