diff --git a/.agents/skills/create-enricher/SKILL.md b/.agents/skills/create-enricher/SKILL.md index c2dfee64..d900f098 100644 --- a/.agents/skills/create-enricher/SKILL.md +++ b/.agents/skills/create-enricher/SKILL.md @@ -69,11 +69,11 @@ Add tests to `packages/evlog/test/toolkit/enrichers.test.ts`, following the exis Required test categories: -1. **Sets field from headers**: verify the enricher populates the event field correctly -2. **Skips when source data missing**: verify no field is set when the required header/input is absent +1. **Sets the field from its source**: verify the enricher populates the event field correctly, reading whatever it actually reads (`ctx.request`, `ctx.response`, `process.env`, `ctx.event`, or headers) +2. **Skips when source data missing**: verify no field is set when the required input is absent 3. **Preserves existing data**: verify `overwrite: false` (default) doesn't replace user-provided fields 4. **Overwrites when requested**: verify `overwrite: true` replaces existing fields -5. **Handles edge cases**: empty strings, malformed values, case-insensitive header names +5. **Handles edge cases**: empty strings and malformed values, plus case-insensitive lookup for a header-based enricher 6. **Default composition**: if the enricher joined `createDefaultEnrichers()`, extend that composition's tests ## Step 3: Update the Enrichers Docs Page diff --git a/.agents/skills/create-framework-integration/SKILL.md b/.agents/skills/create-framework-integration/SKILL.md index 8bd32a46..dd254b2d 100644 --- a/.agents/skills/create-framework-integration/SKILL.md +++ b/.agents/skills/create-framework-integration/SKILL.md @@ -215,7 +215,7 @@ On top of the matrix, cover the framework-specific surface: 4. Context accumulation. `logger.set()` data appears in the emitted event 5. Drain / enrich / keep callbacks (use `createPipelineSpies()`, `assertHttpEventEmitted`, `waitForDrainCalls`, `findEventViaDrain` from `test/helpers/framework.ts`) 6. Drain/enrich error resilience. Errors there never break the request -7. `useLogger()`: same logger as the native accessor, works across async boundaries, throws outside context +7. `useLogger()`: same logger as the native accessor, works across async boundaries, throws outside context. Skip it for an integration without ALS, and test the accessor it ships instead: on Workers that is the handler's fourth argument, from `defineWorkerFetch` / `withEvlog` 8. Streaming (if applicable). Event deferred until the body closes Use fake timers for anything time-based; `defined()` instead of `!`. @@ -246,7 +246,7 @@ links: 1. **Quick Start**: install + register middleware (copy-paste minimum setup) 2. **Wide Events**: progressive `log.set()` usage -3. **useLogger()**: accessing logger from services without passing the request +3. **useLogger()**: accessing the logger from services without passing the request, or, for an integration without ALS, the accessor it ships in its place 4. **Error Handling**: `createError()` + `parseError()` + framework error handler 5. **Drain & Enrichers**: middleware options with inline example 6. **Pipeline (Batching & Retry)**: `createDrainPipeline` example @@ -290,7 +290,7 @@ Icons use Simple Icons format: `i-simple-icons-{name}`. In `apps/docs/skills/review-logging-patterns/SKILL.md` (published on evlog.dev): 1. Add `### {Framework}` in the **"Framework Setup"** section, in the same order as the docs -2. Include: import + `initLogger` + middleware setup; native logger access; `useLogger()` snippet; full pipeline example (`drain`, `enrich`, `keep`) +2. Include: import + `initLogger` + middleware setup; native logger access; a `useLogger()` snippet, or the accessor that replaces it when the integration has no ALS; full pipeline example (`drain`, `enrich`, `keep`) 3. Update the `description:` line in the YAML frontmatter to mention the new framework name ## Step 10: Update README @@ -310,7 +310,7 @@ The app must include: 1. **`evlog()` middleware** with `drain` (PostHog) and `enrich` callbacks 2. **Health route**: basic `log.set()` usage -3. **Data route**: context accumulation with user/business data, using `useLogger()` in a service function +3. **Data route**: context accumulation with user/business data, using `useLogger()` in a service function, or the integration's own accessor when it has no ALS 4. **Error route**: `createError()` with status/why/fix/link 5. **Error handler**: framework's error handler with `parseError()` + manual `log.error()` 6. **Test UI**: served at `/`, a self-contained HTML page with buttons to hit each route and display JSON responses diff --git a/.agents/skills/write-evlog-content/references/corrections.md b/.agents/skills/write-evlog-content/references/corrections.md index 6e82cd24..178ba221 100644 --- a/.agents/skills/write-evlog-content/references/corrections.md +++ b/.agents/skills/write-evlog-content/references/corrections.md @@ -73,11 +73,11 @@ Applies to: `::card` on every surface. `metrics.mjs` leaves card bodies out of t Flagged: `Exit codes`, `The JSON contract`, `The map file`, `Monorepos` on the CLI pages, and 19 other pages of the same shape. Actual: `ai-tells.md` already named the twin, parallel headings over parallel entries, and in the file that looks like a section holding a table or a fence and almost no prose. The tell is a mould over sections that argue. -Applies to: every surface. `metrics.mjs` measures the share of sections that list, and `T-06` drops above 0.6, which cleared 20 pages. +Applies to: every surface. `metrics.mjs` measures the share of sections that list, and `T-06` drops at 0.6 or above, which cleared 20 pages. ## 2026-08-15 · U-14 · A bullet is prose -Flagged: nothing, for a year. The rule only ever read headings and paragraphs, so 273 dashes sat in list items untouched, most of them in the `Next steps` list at the bottom of a page. +Flagged: nothing, for a year. The rule only ever read headings and paragraphs, so 276 dashes sat in list items untouched, most of them in the `Next steps` list at the bottom of a page. Actual: 159 were a bold term glossed after a dash, which the corpus elsewhere writes with a colon. The remaining 117 put a full clause after the dash and need a reader. Applies to: list items on every surface. Table cells stay out: a cell is a fragment and a dash between two of its parts is layout. diff --git a/.changeset/olive-pans-shake.md b/.changeset/olive-pans-shake.md new file mode 100644 index 00000000..de1ff1d9 --- /dev/null +++ b/.changeset/olive-pans-shake.md @@ -0,0 +1,4 @@ +--- +--- + +Fixes a typo in the CLI README. No published behaviour changes. diff --git a/AGENTS.md b/AGENTS.md index f72e5e58..dbaf3927 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ scripts/ Repo tooling (run-app, cli-sandbox, release-notes, co - `evlog/browser` is deprecated, use `evlog/http` instead. - Every framework integration exposes the **same contract**: `evlog()` middleware, `useLogger()`, `log.fork()`, and the full `BaseEvlogOptions` surface. Framework-native accessors (`c.get('log')`, `req.log`, `event.locals.log`, `context.get(loggerContext)`) stay alongside it. They are the idiomatic path inside handlers, `useLogger()` is for the layers underneath. When adding an integration, provide both. - `useLogger()` is backed by `AsyncLocalStorage`. On Cloudflare Workers that needs the `nodejs_compat` / `nodejs_als` flag, so `evlog/workers` deliberately has no `useLogger()` and passes the logger as the handler's fourth argument instead. -- New export? Update both `packages/evlog/package.json` exports and `packages/evlog/tsdown.config.ts`. +- New export? Update `packages/evlog/package.json` exports, its `typesVersions`, and `packages/evlog/tsdown.config.ts`. A subpath missing from `typesVersions` resolves at runtime and fails to type-check. - Creating a new adapter, enricher, or framework integration? Read the matching skill at `.agents/skills/` **before starting**: - `.agents/skills/create-adapter/SKILL.md` - `.agents/skills/create-enricher/SKILL.md` diff --git a/apps/docs/content/2.learn/0.overview.md b/apps/docs/content/2.learn/0.overview.md index b812e30e..76db8d8b 100644 --- a/apps/docs/content/2.learn/0.overview.md +++ b/apps/docs/content/2.learn/0.overview.md @@ -158,7 +158,7 @@ None of these is an "upgrade" of another. Use `log` and `createLogger` in the sa All three modes share the same foundation: - **Pretty output** in development, **JSON** in production (default, no configuration needed) -- **Drain pipeline** to send events to Axiom, Sentry, PostHog, and more, see [Integrate / Adapters](/integrate/adapters/overview) +- **Drain pipeline** to send events to Axiom, Sentry, PostHog, and more. See [Integrate / Adapters](/integrate/adapters/overview) - **Structured errors** with `why`, `fix`, and `link`, plus optional backend-only **`internal`** for logs - **Sampling** (head + tail) to control log volume in production - **Redaction** that wipes secrets before they ever leave the process diff --git a/apps/docs/content/5.use-cases/3.better-auth/01.overview.md b/apps/docs/content/5.use-cases/3.better-auth/01.overview.md index ec5a98d4..add2848b 100644 --- a/apps/docs/content/5.use-cases/3.better-auth/01.overview.md +++ b/apps/docs/content/5.use-cases/3.better-auth/01.overview.md @@ -63,7 +63,7 @@ Add Better Auth user identification to my app with evlog. - Import createAuthMiddleware from 'evlog/better-auth' - Call createAuthMiddleware(auth) to get an identify function - Call identify(log, headers, path) in your middleware/hook to auto-identify users on every request -- Safe by default. Only extracts whitelisted fields, never logs passwords or tokens +- Safe by default. Only extracts whitelisted fields, and never logs passwords or tokens - Supports include/exclude route patterns, lifecycle hooks, and Better Auth plugin fields - Works with all frameworks: Nuxt, Next.js, Express, Hono, Fastify, NestJS, Elysia, standalone diff --git a/apps/docs/content/5.use-cases/4.audit/05.compliance.md b/apps/docs/content/5.use-cases/4.audit/05.compliance.md index 86e6b0f5..ba9c6c7e 100644 --- a/apps/docs/content/5.use-cases/4.audit/05.compliance.md +++ b/apps/docs/content/5.use-cases/4.audit/05.compliance.md @@ -81,7 +81,7 @@ Document the chosen window in your security policy. Auditors care about the writ - **Logging only successes.** Auditors care most about denials. Always pair `log.audit()` with `log.audit.deny()` on the negative branch of every authorisation check. - **Leaking PII through `changes`.** `auditDiff()` runs through your `RedactConfig`, but only if the field paths are listed. Add `password`, `token`, `apiKey`, etc. once globally so you never have to think about it again. -- **Treating audits as observability.** Don't sample, downsample, or summarise audit events. Force-keep is on by default, don't disable it. +- **Treating audits as observability.** Don't sample, downsample, or summarise audit events. Force-keep is on by default. Do not disable it. - **Conflating `actor.id` with the session id.** `actor.id` is the stable user id (or system identity). Correlate sessions via `context.requestId` / `context.traceId`, never via the actor. - **Forgetting standalone jobs.** Cron tasks, queue workers, and CLIs trigger audit-worthy actions too. Use `audit()` (no request) or `withAudit()` to keep coverage parity with your HTTP routes. - **Skipping `await: true` on the audit drain.** Without it, audits are fire-and-forget. A crash between the event being emitted and the drain flushing means the action happened but no audit row exists. diff --git a/apps/docs/content/5.use-cases/5.eve.md b/apps/docs/content/5.use-cases/5.eve.md index 8a1af5ab..55369aaa 100644 --- a/apps/docs/content/5.use-cases/5.eve.md +++ b/apps/docs/content/5.use-cases/5.eve.md @@ -36,7 +36,7 @@ Add evlog wide events to my eve agent. - In tools, import useLogger from 'evlog/eve' and call useLogger() inside execute(). The turn logger is bound via AsyncLocalStorage when defineEvlogHook() is registered; pass ctx only if ALS is unavailable in your runtime - User message content is omitted by default (message: 'omit'); use 'preview' or 'full' only after reviewing PII policy - Optionally add agent/instrumentation.ts with defineEvlogInstrumentation from 'evlog/eve' to join OTel spans to the wide events -- Keep eve Agent Runs, evlog/eve is additive +- Keep eve Agent Runs. evlog/eve is additive Docs: https://www.evlog.dev/use-cases/eve Adapters: https://www.evlog.dev/integrate/adapters/overview diff --git a/apps/docs/content/6.extend/1.stream.md b/apps/docs/content/6.extend/1.stream.md index 1838ed2d..1fb36981 100644 --- a/apps/docs/content/6.extend/1.stream.md +++ b/apps/docs/content/6.extend/1.stream.md @@ -42,7 +42,7 @@ Wire an in-process subscriber on top of evlog's stream drain. - Subscribe with `stream.subscribe((event) => ...)` for sync listeners or `for await (const event of stream.events())` for async iteration - Seed history for late subscribers with `stream.recent()` (snapshot of the ring buffer) before opening the live iterator - Tune `buffer` for replay history and `perSubscriberQueue` for slow-consumer backpressure -- Skip on serverless platforms. The stream is in-process, isolated invocations won't share it +- Skip on serverless platforms. The stream is in-process, so isolated invocations won't share it Docs: https://www.evlog.dev/extend/stream :: @@ -106,7 +106,7 @@ Turn on the local stream server so I can subscribe to wide events from a browser - Detect my framework and opt in explicitly (Nuxt: `evlog.stream: true` in `nuxt.config.ts`; Next.js: `defineStreamedInstrumentation({ stream: true })` in `instrumentation.ts`; Hono/Express/Fastify/Elysia/standalone: call `startStreamServer()` once at boot and register the returned `drain` on the evlog drain hook) - Never enable in production by default; gate it behind `process.env.NODE_ENV !== 'production'` or a feature flag - For shared dev environments, set `token: process.env.EVLOG_STREAM_TOKEN` and have the consumer send it as `Authorization: Bearer ` on every request -- Discover the URL from `.evlog/stream.url` (or `/api/_evlog/stream-info` on Nuxt), never hard-code the port, which is ephemeral +- Discover the URL from `.evlog/stream.url` (or `/api/_evlog/stream-info` on Nuxt), and never hard-code the port, which is ephemeral - Skip on serverless platforms. The server is in-process Docs: https://www.evlog.dev/extend/stream diff --git a/apps/docs/content/6.extend/2.fs-reader.md b/apps/docs/content/6.extend/2.fs-reader.md index bc6637aa..30dba0fc 100644 --- a/apps/docs/content/6.extend/2.fs-reader.md +++ b/apps/docs/content/6.extend/2.fs-reader.md @@ -27,7 +27,7 @@ Build a script that consumes evlog's local NDJSON history (no app hook required) - For replay: import `readFsLogs` from `evlog/fs` and iterate `for await (const event of readFsLogs({ since, until, level, filter }))` - For follow mode: import `tailFsLogs` and iterate the same way. It watches for new lines, handles rotation, and accepts an `AbortSignal` - Apply filters at read time (`level`, `since`, `until`, custom `filter` predicate) instead of post-processing -- Treat malformed lines as silently skipped (partial writes happen), never crash the script on a bad line +- Treat malformed lines as silently skipped (partial writes happen), and never crash the script on a bad line Docs: https://www.evlog.dev/extend/fs-reader :: diff --git a/apps/docs/content/6.extend/3.consumer-recipes.md b/apps/docs/content/6.extend/3.consumer-recipes.md index 07a313ef..cb4928be 100644 --- a/apps/docs/content/6.extend/3.consumer-recipes.md +++ b/apps/docs/content/6.extend/3.consumer-recipes.md @@ -24,7 +24,7 @@ Bootstrap a local devtool or dashboard that consumes evlog wide events. - For SSE: discover the URL via `.evlog/stream.url` or `GET /api/_evlog/stream-info`, never hard-code the port - Open an `EventSource` and decode messages as `{ evlog: '1', type, data }` envelopes (`type` is `hello | event | replay | ping`) - For browser tabs running on a different origin from the dev server, configure CORS via the stream server `cors` option and forward credentials carefully -- Aggregate on the consumer side (counts, latency histograms, error groups), keep the server simple +- Aggregate on the consumer side (counts, latency histograms, error groups), and keep the server simple - Skip on serverless platforms. The stream is in-process Docs: https://www.evlog.dev/extend/consumer-recipes diff --git a/apps/docs/content/6.extend/6.tail-sampling.md b/apps/docs/content/6.extend/6.tail-sampling.md index 3390d578..2a5e221d 100644 --- a/apps/docs/content/6.extend/6.tail-sampling.md +++ b/apps/docs/content/6.extend/6.tail-sampling.md @@ -38,7 +38,7 @@ Set up tail sampling so I keep all errors and slow requests while dropping healt - Start with the built-in declarative rules: `evlog.sampling.keep = { status: '>=400', duration: '>1000', path: ['/api/auth/*'] }` - For multi-field or derived conditions, register an `evlog:emit:keep` hook (Nitro: `nitroApp.hooks.hook('evlog:emit:keep', (ctx) => ...)`); set `ctx.shouldKeep = true` to keep -- Keep the hook fast. It runs on every request after enrichment; no I/O, no async work +- Keep the hook fast. It runs on every request after enrichment, so no I/O and no async work - Combine with head sampling (e.g. 10% of healthy traffic) by setting both `sample` (head) and `keep` (tail) - Always keep error events (`level: 'error'`) regardless of sampling; double-check rules don't accidentally drop them diff --git a/apps/docs/content/6.extend/8.custom-drains.md b/apps/docs/content/6.extend/8.custom-drains.md index 6799bf3c..ed0de802 100644 --- a/apps/docs/content/6.extend/8.custom-drains.md +++ b/apps/docs/content/6.extend/8.custom-drains.md @@ -45,7 +45,7 @@ actions: Build a custom evlog drain that ships wide events to a backend without a built-in adapter. -- For HTTP backends, use `defineHttpDrain({ name, resolve, encode })` from `evlog/toolkit`, never call `fetch` directly +- For HTTP backends, use `defineHttpDrain({ name, resolve, encode })` from `evlog/toolkit`, and never call `fetch` directly - For non-HTTP transports (queue, DB, native SDK, raw socket), use `defineDrain({ name, send })` and implement `send(events)` myself - Resolve config lazily inside `resolve()` via `resolveAdapterConfig(namespace, fields, overrides)` so users get the standard precedence (overrides → `runtimeConfig.evlog.` → env) - Use the standardized field names: `apiKey` for bearer secrets, `endpoint` for the base URL, `serviceName`, `timeout` diff --git a/apps/docs/content/6.extend/9.drain-pipeline.md b/apps/docs/content/6.extend/9.drain-pipeline.md index 5b0a3305..615bda17 100644 --- a/apps/docs/content/6.extend/9.drain-pipeline.md +++ b/apps/docs/content/6.extend/9.drain-pipeline.md @@ -204,7 +204,7 @@ actions: Send each wide event to several destinations in parallel through a single drain pipeline. -- Wrap a single `createDrainPipeline` from `evlog/pipeline` around a fan-out function that calls every destination drain inside `Promise.allSettled([drainA(batch), drainB(batch), …])`. `allSettled` so one failing drain doesn't reject the whole batch +- Wrap a single `createDrainPipeline` from `evlog/pipeline` around a fan-out function that calls every destination drain inside `Promise.allSettled([drainA(batch), drainB(batch), …])`. Use `allSettled` so one failing drain doesn't reject the whole batch - Pick destinations by purpose: long-term store (Axiom / Better Stack / Datadog), error tracker (Sentry, typically `{ minLevel: 'error' }` so it doesn't get all events), local replay (`createFsDrain`) - Tune `batch.size`, `batch.intervalMs`, `retry.maxAttempts`, and `maxBufferSize` once at the pipeline level, which applies to all destinations - For destinations that need different filtering, prefer per-drain `minLevel` / `filter` options over wrapping diff --git a/apps/docs/skills/build-audit-logs/SKILL.md b/apps/docs/skills/build-audit-logs/SKILL.md index 7f467dd3..7f7a4359 100644 --- a/apps/docs/skills/build-audit-logs/SKILL.md +++ b/apps/docs/skills/build-audit-logs/SKILL.md @@ -214,7 +214,7 @@ Naming conventions: - `noun.verb` (`invoice.refund`, not `refundInvoice`). - Past tense if the audit is logged after the fact (`invoice.refunded`); present tense when wrapped by `withAudit()` (which resolves the outcome itself). -- Lowercase, dot-delimited, no spaces: for hand-written action ids (`defineAuditAction`, inline `log.audit`). Catalog entries follow the catalog convention instead: UPPER_SNAKE_CASE keys under a lowercase prefix, producing wire actions like `billing.INVOICE_REFUND`. That's intentional, don't lowercase the keys. +- Lowercase, dot-delimited, no spaces: for hand-written action ids (`defineAuditAction`, inline `log.audit`). Catalog entries follow the catalog convention instead: UPPER_SNAKE_CASE keys under a lowercase prefix, producing wire actions like `billing.INVOICE_REFUND`. That is intentional. Do not lowercase the keys. ### Step 3: Instrument call sites diff --git a/packages/cli/README.md b/packages/cli/README.md index 3db03dc3..aa9524db 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -149,7 +149,7 @@ Maintainer notes on frictions / wishlist: [`DEBUG-DX.md`](./DEBUG-DX.md). ## Adding a command -1. Create `src/commands/.ts` with `defineEvlogCommand('name', { run({ args, cli, log, ui }) { … } })`: the header, `--json` / `--debug` / `--no-header`, and debug filet are automatic. Use `log.step` / `log.finding` for diagnostics; `ui.done` / `ui.human` / `ui.json` for output. +1. Create `src/commands/.ts` with `defineEvlogCommand('name', { run({ args, cli, log, ui }) { … } })`: the header, `--json` / `--debug` / `--no-header`, and the debug file is automatic. Use `log.step` / `log.finding` for diagnostics; `ui.done` / `ui.human` / `ui.json` for output. 2. Register it with one import + one line in [`src/commands/index.ts`](src/commands/index.ts). `src/index.ts` stays a thin shell (meta + `withTelemetry`). Do not embed command bodies there. diff --git a/scripts/content-lint/lib/metrics.mjs b/scripts/content-lint/lib/metrics.mjs index 1104d4ac..5919156a 100644 --- a/scripts/content-lint/lib/metrics.mjs +++ b/scripts/content-lint/lib/metrics.mjs @@ -192,8 +192,11 @@ function epigrams(doc) { return { eligible, count: candidates.length, ratio: eligible === 0 ? 0 : round(candidates.length / eligible), candidates } } -/** An en dash between two numbers is a range, and the only mark that reads as one. */ -const NUMERIC_RANGE = /(\d)\s*[—–]\s*(\d)/g +/** + * An en dash between two numbers is a range, and the only mark that reads as + * one. The em dash is not: `30—80` is the banned mark with digits around it. + */ +const NUMERIC_RANGE = /(\d)\s*–\s*(\d)/g /** * Every em dash and en dash in the prose, located (U-14). Not a rate: evlog @@ -345,7 +348,7 @@ function bulletFrames(doc) { const share = top / firsts.length const lengths = list.items.map(item => wordCount(item.text)) if (share >= 0.75 || coefficientOfVariation(lengths) < 0.15) { - locked.push({ line: list.line, items: list.items.length, opening: firsts.length, anaphoraShare: round(share) }) + locked.push({ line: list.line, items: list.items.length, opening: firsts.length, anaphora: top, anaphoraShare: round(share) }) } } diff --git a/scripts/content-lint/lib/metrics.test.mjs b/scripts/content-lint/lib/metrics.test.mjs index 23162cab..738cba61 100644 --- a/scripts/content-lint/lib/metrics.test.mjs +++ b/scripts/content-lint/lib/metrics.test.mjs @@ -126,8 +126,16 @@ describe('dashes, twins', () => { describe('bullet frames, symbols', () => { it('reads a bolded symbol as the symbol it is', () => { - const items = ['`message`', '`evlog`', '`dd`', '`service`', '`timestamp`'] - const source = items.map(name => `- **${name}**: what the field carries and why`).join('\n') + // Uneven bodies, so a frame here could only come from the shared opener and + // never from `coefficientOfVariation`. + const items = [ + ['`message`', 'the one-line summary the list view shows, built from the method, the path and the status'], + ['`evlog`', 'the whole event'], + ['`dd`', 'trace and span ids, when the event carries trace context at all'], + ['`service`', 'the name'], + ['`timestamp`', 'Unix milliseconds'], + ] + const source = items.map(([name, body]) => `- **${name}**: ${body}`).join('\n') expect(measureSource(source).bulletFrames).toEqual([]) }) @@ -175,15 +183,20 @@ describe('bullet frames', () => { const [frame] = measureSource(list).bulletFrames expect(frame.anaphoraShare).toBe(1) + expect(frame.anaphora).toBe(5) expect(frame.opening).toBe(5) expect(frame.items).toBe(5) }) it('reads the word after the ordinal, which the parser has already removed', () => { - const numbered = ['1. Explicit overrides win', '2. Runtime config', '3. Legacy config', '4. Env vars', '5. Defaults'].join('\n') - const [frame] = measureSource(numbered).bulletFrames ?? [] + // The items share `keep` and nothing else. If the ordinal survived, the + // openers would be `1.` through `5.` and the share would be 0.2, so this + // assertion fails in exactly the case it exists to catch. + const numbered = ['1. Keep the buffer small', '2. Keep the batch small', '3. Keep the retries low', '4. Keep the timeout short', '5. Keep the drain fast'].join('\n') + const [frame] = measureSource(numbered).bulletFrames - expect(frame?.anaphoraShare ?? 0).toBeLessThan(0.75) + expect(frame.anaphoraShare).toBe(1) + expect(frame.anaphora).toBe(5) }) it('leaves the code placeholder out of the population', () => { diff --git a/scripts/content-lint/lib/score.mjs b/scripts/content-lint/lib/score.mjs index 46639d3f..6c4fb29a 100644 --- a/scripts/content-lint/lib/score.mjs +++ b/scripts/content-lint/lib/score.mjs @@ -231,7 +231,7 @@ function rhythm(metrics, profile, rates, template) { id: 'T-07', severity: 'standard', line: list.line, - message: `${list.opening} of ${list.items} bullets share one opener; a table in bullet form?`, + message: `${list.anaphora} of ${list.opening} bullets share one opener; a table in bullet form?`, }) } } diff --git a/scripts/content-lint/lib/score.test.mjs b/scripts/content-lint/lib/score.test.mjs index 4957af4a..e61e23d8 100644 --- a/scripts/content-lint/lib/score.test.mjs +++ b/scripts/content-lint/lib/score.test.mjs @@ -182,6 +182,22 @@ describe('evaluate', () => { expect(result.findings.map(finding => finding.id)).not.toContain('T-06') }) + it('pins the enumerating share at its inclusive boundary', () => { + // Five sections, so 3 lists exactly 0.6 and 2 falls under it. The rule is + // `>= 0.6`, and this is the pair that says so. + const titles = ['Exit codes', 'The JSON contract', 'The map file', 'Monorepos', 'Options'] + const prose = 'The gate reads the map file and compares it against the baseline the previous run wrote, so a rule that moved is never counted as a regression by mistake, and the exit code stays the contract.' + const build = listed => titles + .map((title, index) => `## ${title}\n\n${index < listed ? '| Key | Meaning |\n|---|---|\n| a | b |' : prose}`) + .join('\n\n') + + const atTheBoundary = evaluate(page('apps/docs/content/3.cli/a.md', build(3)), quiet) + const underIt = evaluate(page('apps/docs/content/3.cli/a.md', build(2)), quiet) + + expect(atTheBoundary.findings.map(finding => finding.id)).not.toContain('T-06') + expect(underIt.findings.map(finding => finding.id)).toContain('T-06') + }) + it('still flags one mould over sections that argue', () => { const sections = ['Exit codes', 'The JSON contract', 'The map file', 'Monorepos', 'Options'] const prose = 'The gate reads the map file and compares it against the baseline the previous run wrote, so a rule that moved is never counted as a regression by mistake, and the exit code stays the contract.'