Skip to content

Tutorial: Build a custom analyzer

Add a new deterministic analyzer to Refract — one that produces a new event type and integrates into the pipeline. This tutorial walks through the full cycle: define the event type, implement the analyzer, integrate with the CLI, write tests, add an eval benchmark, and verify the build gate.

  • Bun 1.2+
  • Clone of the refract monorepo (git clone https://github.com/refract-org/refract)
  • Passing build gate before you start: bun run ci

All event types live in packages/evidence-graph/src/schemas/evidence.ts. Add your new type to the EventType union. This example adds a "redirect detected" event:

export type EventType =
// ... existing types ...
| "redirect_detected"; // ← add here

Keep the union alphabetically grouped within its category. Bump the EVENT_SCHEMA_VERSION minor version in the same file. It is "0.5.0" today, so a new event type makes it:

export const EVENT_SCHEMA_VERSION = "0.6.0";

Build to verify the type propagates:

Terminal window
cd packages/evidence-graph && bun run build

Create packages/analyzers/src/redirect-detector.ts:

import type { EvidenceEvent, Revision } from "@refract-org/evidence-graph";
export function detectRedirects(revisions: Revision[]): EvidenceEvent[] {
const events: EvidenceEvent[] = [];
for (let i = 1; i < revisions.length; i++) {
const prev = revisions[i - 1];
const curr = revisions[i];
// A redirect is wikitext starting with #REDIRECT
const wasRedirect = prev.content.trimStart().startsWith("#REDIRECT");
const becameRedirect = curr.content.trimStart().startsWith("#REDIRECT");
if (becameRedirect && !wasRedirect) {
events.push({
eventType: "redirect_detected",
fromRevisionId: prev.revId,
toRevisionId: curr.revId,
section: "",
before: prev.content.slice(0, 200),
after: curr.content.slice(0, 200),
deterministicFacts: [{
fact: "redirect_detected",
detail: `page converted to redirect in revision ${curr.revId}`,
provenance: {
analyzer: "redirect-detector",
version: "0.5.0",
inputHashes: [],
},
}],
layer: "observed",
timestamp: curr.timestamp,
});
}
}
return events;
}

Add the export to packages/analyzers/src/index.ts:

export { detectRedirects } from "./redirect-detector.js";

packages/cli/src/commands/analyze.ts assembles a page's events in one place: page moves, then buildRevisionEvents for the per-pair diffs, then talk-page correlation. Add your analyzer's events there, after the buildRevisionEvents call:

import { detectRedirects } from "@refract-org/analyzers";
// sortedRevs is the page's revisions, oldest first.
events.push(...detectRedirects(sortedRevs));

To run it only at one depth, wrap the call in a check on eventDepth.

Create packages/analyzers/src/__tests__/redirect-detector.test.ts:

import { describe, expect, it } from "vitest";
import type { Revision } from "@refract-org/evidence-graph";
import { detectRedirects } from "../redirect-detector.js";
function makeRev(revId: number, content: string, timestamp = "2026-01-01T00:00:00Z"): Revision {
return {
revId, content, comment: "", timestamp,
pageId: 1, pageTitle: "Test", user: undefined,
size: content.length, minor: false,
};
}
describe("detectRedirects", () => {
it("detects when a page becomes a redirect", () => {
const revs = [
makeRev(1, "Normal article text"),
makeRev(2, "#REDIRECT [[Target page]]"),
];
const events = detectRedirects(revs);
expect(events).toHaveLength(1);
expect(events[0].eventType).toBe("redirect_detected");
});
it("does not fire when page stays a redirect", () => {
const revs = [
makeRev(1, "#REDIRECT [[Old target]]"),
makeRev(2, "#REDIRECT [[New target]]"),
];
const events = detectRedirects(revs);
expect(events).toHaveLength(0);
});
it("does not fire when page stays normal", () => {
const revs = [
makeRev(1, "Normal text"),
makeRev(2, "Updated normal text"),
];
expect(detectRedirects(revs)).toHaveLength(0);
});
});

Run the tests:

Terminal window
bun run test

In packages/eval/src/index.ts, the benchmarkPages() method returns test cases. Add one for your analyzer:

{
id: "redirect-detection",
description: "Pages that were converted to redirects produce redirect_detected events",
pageTitle: "Wikipedia:Redirect", // a known redirect
pageId: 12345,
revisionRange: { from: 0, to: 0 },
expectedEvents: [{ eventType: "redirect_detected", section: "" }],
tolerance: { minEventCount: 1, minPrecision: 0.0 },
},

Run the eval:

Terminal window
refract eval --page "Wikipedia:Redirect"
Terminal window
bun run ci

This runs the build, biome ci, the typecheck, check:boundaries, the tests and the release check (check:release -- --pack). All must pass. If your analyzer introduces exports that cross package boundaries incorrectly, check:boundaries will catch it.

  • Add the new event type to apps/docs/src/content/docs/schema.md
  • Add it to apps/docs/src/content/docs/events.md event taxonomy
  • Add it to EVENT_TYPE_RECORD in apps/ui/src/types.ts; the viewer's build fails until you do
  • If the CLI flag changed, update apps/docs/src/content/docs/cli.md
  • File: kebab-case (redirect-detector.ts)
  • Function: camelCase (detectRedirects)
  • Imports: import type for types, .js extension for intra-package
  • Test file: src/__tests__/redirect-detector.test.ts
  • Exports: only the public API from index.ts
  • Event types: snake_case (redirect_detected)
  • No model calls in the analyzer
  • No comments explaining what code does (only non-obvious constraints)
  • Commit: feat(analyzers): add redirect detector