stilltrue 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,11 +32,47 @@ Drift has three outcomes, never two: `pass`, `rot` (facts changed — fail the
32
32
  build), and `error` (source unreachable — warn, don't fail). Conflating the
33
33
  last two causes alarm fatigue.
34
34
 
35
+ ## Quick start
36
+
37
+ ```ts
38
+ // stilltrue.config.ts (or .mjs / .js)
39
+ import { defineStilltrue, corpus, json, surname } from 'stilltrue';
40
+
41
+ export default defineStilltrue({
42
+ drift: [
43
+ {
44
+ name: 'school-board-roster',
45
+ source: corpus(['https://district.example.org/board']),
46
+ expect: json('./data/school-board.json', (d) => d.members.map((m) => surname(m.name))),
47
+ compare: 'contains-all',
48
+ },
49
+ ],
50
+ });
51
+ ```
52
+
53
+ ```yaml
54
+ # .github/workflows/drift.yml
55
+ on:
56
+ schedule: [{ cron: '17 14 * * 3' }] # off-beat minute: GitHub drops :00 crons
57
+ jobs:
58
+ drift:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - uses: actions/setup-node@v4
63
+ with: { node-version: '22' }
64
+ - run: npm ci
65
+ - run: npx stilltrue drift
66
+ ```
67
+
68
+ `source` is your code (fetch+parse, or LLM extraction — the comparison stays
69
+ deterministic either way). `expect` is your curated file. Rot fails the run
70
+ with one message per vanished fact; unreachable sources only warn.
71
+
35
72
  ## Status
36
73
 
37
- Pre-v0.1. Current state: config seam design (`src/config.ts`) validated
38
- against two real production checks (`examples/stilltrue.config.ts`). Next:
39
- the drift runner, then swapping AskGwinnett's bespoke GitHub Actions to it.
74
+ v0.1 — `drift` is real and running in production (AskGwinnett). `golden` and
75
+ `verify` are designed but not yet shipped; see the brief.
40
76
 
41
77
  ## License
42
78
 
package/dist/cli.js CHANGED
@@ -1,19 +1,77 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * npx stilltrue drift — run drift checks (v0.1 scope)
4
- * npx stilltrue golden — regression evals (later)
5
- * npx stilltrue report — HTML drift report (later)
3
+ * npx stilltrue drift [--config <path>] [--only <name,name>] [--json <path>]
4
+ *
5
+ * Exit codes: 0 = all pass (errors warn but never fail — a judge/source
6
+ * outage must not block CI), 1 = rot detected or usage error.
6
7
  */
7
- const [command] = process.argv.slice(2);
8
- switch (command) {
9
- case 'drift':
10
- console.error('stilltrue drift: not implemented yet — see docs/BRIEF.md, First session §3');
11
- process.exit(1);
12
- case undefined:
13
- console.log('usage: stilltrue <drift|golden|report>');
14
- process.exit(1);
15
- default:
16
- console.error(`stilltrue: unknown command "${command}"`);
17
- process.exit(1);
8
+ import { writeFile } from 'node:fs/promises';
9
+ import { parseArgs } from 'node:util';
10
+ import { findConfigPath, loadConfig } from "./loadConfig.js";
11
+ import { runDrift } from "./runner.js";
12
+ const GH = !!process.env.GITHUB_ACTIONS;
13
+ function printResult(r) {
14
+ const icon = { pass: '✓', rot: '✗', error: '⚠' }[r.outcome];
15
+ console.log(`${icon} ${r.name} — ${r.outcome} (${r.durationMs}ms)`);
16
+ for (const w of r.warnings) {
17
+ console.log(` warning: ${w}`);
18
+ if (GH)
19
+ console.log(`::warning::${r.name}: ${w}`);
20
+ }
21
+ for (const m of r.messages) {
22
+ console.log(` ${m}`);
23
+ if (GH)
24
+ console.log(`::${r.outcome === 'rot' ? 'error' : 'warning'}::${r.name}: ${m}`);
25
+ }
26
+ }
27
+ async function drift(argv) {
28
+ const { values } = parseArgs({
29
+ args: argv,
30
+ options: {
31
+ config: { type: 'string' },
32
+ only: { type: 'string' },
33
+ json: { type: 'string' },
34
+ },
35
+ });
36
+ const configPath = findConfigPath(process.cwd(), values.config);
37
+ const { config, configDir } = await loadConfig(configPath);
38
+ if (!config.drift?.length) {
39
+ console.error('no drift checks configured');
40
+ return 1;
41
+ }
42
+ const results = await runDrift(config, {
43
+ configDir,
44
+ only: values.only ? values.only.split(',').map((s) => s.trim()) : undefined,
45
+ });
46
+ for (const r of results)
47
+ printResult(r);
48
+ const rotted = results.filter((r) => r.outcome === 'rot').length;
49
+ const errored = results.filter((r) => r.outcome === 'error').length;
50
+ const passed = results.filter((r) => r.outcome === 'pass').length;
51
+ console.log(`\n${passed} pass, ${rotted} rot, ${errored} error (errors warn, never fail)`);
52
+ if (values.json) {
53
+ await writeFile(values.json, JSON.stringify(results, null, 2));
54
+ console.log(`results written to ${values.json}`);
55
+ }
56
+ return rotted > 0 ? 1 : 0;
57
+ }
58
+ const [command, ...rest] = process.argv.slice(2);
59
+ try {
60
+ switch (command) {
61
+ case 'drift':
62
+ process.exit(await drift(rest));
63
+ break;
64
+ case 'golden':
65
+ case 'report':
66
+ console.error(`stilltrue ${command}: not implemented yet — drift shipped first (docs/BRIEF.md)`);
67
+ process.exit(1);
68
+ break;
69
+ default:
70
+ console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>]');
71
+ process.exit(command ? 1 : 0);
72
+ }
73
+ }
74
+ catch (err) {
75
+ console.error(`stilltrue: ${err instanceof Error ? err.message : String(err)}`);
76
+ process.exit(1);
18
77
  }
19
- export {};
@@ -0,0 +1,2 @@
1
+ import type { Compare, CompareFn } from './config.ts';
2
+ export declare function resolveCompare<E, A>(compare: Compare<E, A> | undefined): CompareFn<E, A>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 'contains-all': expected is a list of marker strings, actual is a text
3
+ * corpus. Case-insensitive. The drift signal used by the original
4
+ * school-board check: a curated name/fact vanishing from the live pages.
5
+ */
6
+ function containsAll(expected, actual) {
7
+ if (!Array.isArray(expected)) {
8
+ throw new TypeError(`contains-all: expected must be string[], got ${typeof expected}`);
9
+ }
10
+ if (typeof actual !== 'string') {
11
+ throw new TypeError(`contains-all: actual must be a string corpus, got ${typeof actual}`);
12
+ }
13
+ const corpus = actual.toLowerCase();
14
+ return expected
15
+ .filter((marker) => !corpus.includes(String(marker).toLowerCase()))
16
+ .map((marker) => `marker no longer present in source: "${marker}"`);
17
+ }
18
+ function isPlainObject(v) {
19
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
20
+ }
21
+ /** Structural deep-equal with pathed mismatch messages. */
22
+ function deepEqual(expected, actual, path = '$') {
23
+ if (Array.isArray(expected) && Array.isArray(actual)) {
24
+ const messages = [];
25
+ if (expected.length !== actual.length) {
26
+ messages.push(`${path}: length ${expected.length} → ${actual.length}`);
27
+ }
28
+ const len = Math.min(expected.length, actual.length);
29
+ for (let i = 0; i < len; i++) {
30
+ messages.push(...deepEqual(expected[i], actual[i], `${path}[${i}]`));
31
+ }
32
+ return messages;
33
+ }
34
+ if (isPlainObject(expected) && isPlainObject(actual)) {
35
+ const messages = [];
36
+ for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
37
+ if (!(key in actual))
38
+ messages.push(`${path}.${key}: missing from source`);
39
+ else if (!(key in expected))
40
+ messages.push(`${path}.${key}: unexpected new value ${JSON.stringify(actual[key])}`);
41
+ else
42
+ messages.push(...deepEqual(expected[key], actual[key], `${path}.${key}`));
43
+ }
44
+ return messages;
45
+ }
46
+ if (expected !== actual) {
47
+ return [`${path}: expected ${JSON.stringify(expected)}, source has ${JSON.stringify(actual)}`];
48
+ }
49
+ return [];
50
+ }
51
+ export function resolveCompare(compare) {
52
+ if (compare === undefined || compare === 'deep-equal')
53
+ return (e, a) => deepEqual(e, a);
54
+ if (compare === 'contains-all')
55
+ return (e, a) => containsAll(e, a);
56
+ return compare;
57
+ }
package/dist/config.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * The config seam — the entire product is "the package owns the *how*,
3
- * the consumer's config owns the *what*." (docs/BRIEF.md, First session §2)
4
- *
5
- * Designed against two real checks before the runner exists:
6
- * see examples/stilltrue.config.ts (GCPS board roster + BOC refresh).
2
+ * The config seam — the package owns the *how*, the consumer's config owns
3
+ * the *what*. Designed against the real production check it replaces
4
+ * (bfoster-services scripts/check-school-board-drift.js): fetch pages into a
5
+ * text corpus, assert curated markers still appear, tolerate partial fetch
6
+ * failures with warnings, and never conflate "facts changed" with "source
7
+ * unreachable".
7
8
  */
8
- /** What a drift check found. Three outcomes, never two (BRIEF §First session 4). */
9
+ /** Three outcomes, never two (docs/BRIEF.md, First session §4). */
9
10
  export type DriftOutcome =
10
- /** Live source still matches the expected file. */
11
+ /** Live source still matches the curated facts. */
11
12
  'pass'
12
13
  /** Facts no longer match — fail the build. A human fixes. */
13
14
  | 'rot'
@@ -16,36 +17,45 @@ export type DriftOutcome =
16
17
  export interface DriftResult {
17
18
  name: string;
18
19
  outcome: DriftOutcome;
19
- /** Human-readable detail: the diff for `rot`, the cause for `error`. */
20
+ /** One issue per entry: the mismatches for `rot`, the cause for `error`. */
20
21
  messages: string[];
21
- /** Structured diff when outcome is `rot`, for the HTML report. */
22
- diff?: {
23
- expected: unknown;
24
- actual: unknown;
25
- };
22
+ /** Non-fatal notes (e.g. one of several pages unreachable). */
23
+ warnings: string[];
26
24
  checkedAt: string;
25
+ durationMs: number;
26
+ }
27
+ /** Passed to consumer `source`/`expect` functions. */
28
+ export interface CheckContext {
29
+ /** Record a non-fatal problem (partial fetch failure, odd markup, …). */
30
+ warn: (message: string) => void;
31
+ /** Absolute directory of the loaded config file — resolve data paths from here. */
32
+ configDir: string;
27
33
  }
28
34
  /**
29
- * One curated fact vs its live authoritative source.
35
+ * Deterministic comparison: returns mismatch messages, [] = pass.
36
+ * Built-ins: 'contains-all' (expected: string[] markers, actual: corpus
37
+ * string, case-insensitive) and 'deep-equal'.
38
+ */
39
+ export type CompareFn<E, A> = (expected: E, actual: A) => string[];
40
+ export type Compare<E, A> = 'contains-all' | 'deep-equal' | CompareFn<E, A>;
41
+ /**
42
+ * One curated fact-set vs its live authoritative source.
30
43
  *
31
- * `source()` is the consumer's code — it may fetch+parse deterministically
32
- * or use an LLM to extract structure from a messy page; the package neither
33
- * knows nor cares. The comparison stays deterministic either way.
44
+ * `source()` is consumer code — deterministic fetch+parse or LLM extraction,
45
+ * the package neither knows nor cares; a throw means outcome `error`, never
46
+ * `rot`. The comparison is always deterministic.
34
47
  */
35
- export interface DriftCheck<T = unknown> {
48
+ export interface DriftCheck<E = unknown, A = unknown> {
36
49
  name: string;
37
- /** Fetch the live source and return structured data. Throw => `error`, never `rot`. */
38
- source: () => Promise<T>;
39
- /** Path to the curated expected file (JSON), or the expected value itself. */
40
- expect: string | T;
41
- /**
42
- * How to compare. A named built-in ('deep-equal', 'surnames', ...) or a
43
- * custom deterministic function returning mismatch messages ([] = pass).
44
- */
45
- compare?: string | ((expected: T, actual: T) => string[]);
50
+ /** Fetch the live source. Throw => `error`. */
51
+ source: (ctx: CheckContext) => Promise<A>;
52
+ /** The curated expectation: a value, or a function deriving it (e.g. from a data file). */
53
+ expect: E | ((ctx: CheckContext) => E | Promise<E>);
54
+ /** Defaults to 'deep-equal'. */
55
+ compare?: Compare<E, A>;
46
56
  }
47
57
  export interface StilltrueConfig {
48
- drift?: DriftCheck<any>[];
58
+ drift?: DriftCheck<any, any>[];
49
59
  /** golden and verify are later phases — see docs/BRIEF.md build plan. */
50
60
  golden?: unknown;
51
61
  verify?: unknown;
package/dist/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The config seam — the entire product is "the package owns the *how*,
3
- * the consumer's config owns the *what*." (docs/BRIEF.md, First session §2)
4
- *
5
- * Designed against two real checks before the runner exists:
6
- * see examples/stilltrue.config.ts (GCPS board roster + BOC refresh).
2
+ * The config seam — the package owns the *how*, the consumer's config owns
3
+ * the *what*. Designed against the real production check it replaces
4
+ * (bfoster-services scripts/check-school-board-drift.js): fetch pages into a
5
+ * text corpus, assert curated markers still appear, tolerate partial fetch
6
+ * failures with warnings, and never conflate "facts changed" with "source
7
+ * unreachable".
7
8
  */
8
9
  /** Identity function for typed configs: `export default defineStilltrue({...})`. */
9
10
  export function defineStilltrue(config) {
@@ -0,0 +1,28 @@
1
+ import type { CheckContext } from './config.ts';
2
+ export interface CorpusOptions {
3
+ /** Per-page fetch timeout, ms. Default 20000. */
4
+ timeoutMs?: number;
5
+ /** Sent as User-Agent so site operators can identify the check. */
6
+ userAgent?: string;
7
+ }
8
+ /** Strip a page to comparable text (same normalization as the original check). */
9
+ export declare function htmlToText(html: string): string;
10
+ /**
11
+ * Source helper: fetch pages and concatenate them into one text corpus.
12
+ * Individual unreachable pages warn; ALL pages unreachable throws, which the
13
+ * runner reports as `error` (source down ≠ facts rotted).
14
+ */
15
+ export declare function corpus(urls: string[], options?: CorpusOptions): (ctx: CheckContext) => Promise<string>;
16
+ /**
17
+ * Expect helper: read a curated JSON data file (path relative to the config
18
+ * file) and optionally derive the comparable expectation from it — e.g. the
19
+ * marker list for 'contains-all'.
20
+ */
21
+ export declare function json<T = any, E = T>(path: string, derive?: (data: T) => E): (ctx: CheckContext) => Promise<E>;
22
+ /**
23
+ * Surname as the drift signal — titles and first names vary in page copy
24
+ * ("Dr. Jane Smith" vs "Superintendent Smith"); the surname is stable.
25
+ * Leading honorifics and trailing generational suffixes are stripped
26
+ * ("Jasper Watkins III" → "Watkins").
27
+ */
28
+ export declare function surname(fullName: string): string;
@@ -0,0 +1,69 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ /** Strip a page to comparable text (same normalization as the original check). */
4
+ export function htmlToText(html) {
5
+ return html
6
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
7
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
8
+ .replace(/<[^>]+>/g, ' ')
9
+ .replace(/&amp;/g, '&')
10
+ .replace(/&nbsp;/g, ' ')
11
+ .replace(/\s+/g, ' ');
12
+ }
13
+ /**
14
+ * Source helper: fetch pages and concatenate them into one text corpus.
15
+ * Individual unreachable pages warn; ALL pages unreachable throws, which the
16
+ * runner reports as `error` (source down ≠ facts rotted).
17
+ */
18
+ export function corpus(urls, options = {}) {
19
+ const { timeoutMs = 20_000, userAgent = 'stilltrue drift check (github.com/sg-bfoster/stilltrue)' } = options;
20
+ return async (ctx) => {
21
+ let text = '';
22
+ const unreachable = [];
23
+ for (const url of urls) {
24
+ try {
25
+ const res = await fetch(url, {
26
+ signal: AbortSignal.timeout(timeoutMs),
27
+ headers: { 'User-Agent': userAgent },
28
+ });
29
+ if (!res.ok)
30
+ throw new Error(`HTTP ${res.status}`);
31
+ text += ` ${htmlToText(await res.text())}`;
32
+ }
33
+ catch (err) {
34
+ unreachable.push(`${url} (${err instanceof Error ? err.message : String(err)})`);
35
+ }
36
+ }
37
+ if (!text.trim()) {
38
+ throw new Error(`all pages unreachable: ${unreachable.join('; ')}`);
39
+ }
40
+ for (const miss of unreachable)
41
+ ctx.warn(`page unreachable: ${miss}`);
42
+ return text;
43
+ };
44
+ }
45
+ /**
46
+ * Expect helper: read a curated JSON data file (path relative to the config
47
+ * file) and optionally derive the comparable expectation from it — e.g. the
48
+ * marker list for 'contains-all'.
49
+ */
50
+ export function json(path, derive) {
51
+ return async (ctx) => {
52
+ const data = JSON.parse(await readFile(resolve(ctx.configDir, path), 'utf8'));
53
+ return derive ? derive(data) : data;
54
+ };
55
+ }
56
+ /**
57
+ * Surname as the drift signal — titles and first names vary in page copy
58
+ * ("Dr. Jane Smith" vs "Superintendent Smith"); the surname is stable.
59
+ * Leading honorifics and trailing generational suffixes are stripped
60
+ * ("Jasper Watkins III" → "Watkins").
61
+ */
62
+ export function surname(fullName) {
63
+ const cleaned = String(fullName)
64
+ .replace(/^(dr|mr|mrs|ms)\.?\s+/i, '')
65
+ .replace(/,?\s+(jr|sr|ii|iii|iv|v)\.?$/i, '')
66
+ .trim();
67
+ const parts = cleaned.split(/\s+/);
68
+ return parts[parts.length - 1] ?? '';
69
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,4 @@
1
- export { defineStilltrue, type StilltrueConfig, type DriftCheck, type DriftResult, type DriftOutcome, } from './config.js';
1
+ export { defineStilltrue, type StilltrueConfig, type DriftCheck, type DriftResult, type DriftOutcome, type CheckContext, type Compare, type CompareFn, } from './config.ts';
2
+ export { corpus, json, surname, htmlToText, type CorpusOptions } from './helpers.ts';
3
+ export { runDrift, runDriftCheck, type RunDriftOptions } from './runner.ts';
4
+ export { resolveCompare } from './compare.ts';
package/dist/index.js CHANGED
@@ -1 +1,4 @@
1
- export { defineStilltrue, } from './config.js';
1
+ export { defineStilltrue, } from "./config.js";
2
+ export { corpus, json, surname, htmlToText } from "./helpers.js";
3
+ export { runDrift, runDriftCheck } from "./runner.js";
4
+ export { resolveCompare } from "./compare.js";
@@ -0,0 +1,6 @@
1
+ import type { StilltrueConfig } from './config.ts';
2
+ export declare function findConfigPath(cwd: string, explicit?: string): string;
3
+ export declare function loadConfig(configPath: string): Promise<{
4
+ config: StilltrueConfig;
5
+ configDir: string;
6
+ }>;
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { createJiti } from 'jiti';
4
+ const CANDIDATES = [
5
+ 'stilltrue.config.ts',
6
+ 'stilltrue.config.mts',
7
+ 'stilltrue.config.js',
8
+ 'stilltrue.config.mjs',
9
+ 'stilltrue.config.cjs',
10
+ ];
11
+ export function findConfigPath(cwd, explicit) {
12
+ if (explicit) {
13
+ const p = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
14
+ if (!existsSync(p))
15
+ throw new Error(`config not found: ${p}`);
16
+ return p;
17
+ }
18
+ for (const name of CANDIDATES) {
19
+ const p = join(cwd, name);
20
+ if (existsSync(p))
21
+ return p;
22
+ }
23
+ throw new Error(`no stilltrue.config.{ts,js,mjs,cjs} found in ${cwd}`);
24
+ }
25
+ export async function loadConfig(configPath) {
26
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
27
+ const config = (await jiti.import(configPath, { default: true }));
28
+ if (!config || typeof config !== 'object') {
29
+ throw new Error(`config at ${configPath} has no default export object`);
30
+ }
31
+ return { config, configDir: dirname(configPath) };
32
+ }
@@ -0,0 +1,9 @@
1
+ import type { DriftCheck, DriftResult, StilltrueConfig } from './config.ts';
2
+ export interface RunDriftOptions {
3
+ /** Run only checks whose name is in this list. */
4
+ only?: string[];
5
+ /** Directory the config file was loaded from (data paths resolve here). */
6
+ configDir: string;
7
+ }
8
+ export declare function runDriftCheck(check: DriftCheck<any, any>, configDir: string): Promise<DriftResult>;
9
+ export declare function runDrift(config: StilltrueConfig, options: RunDriftOptions): Promise<DriftResult[]>;
package/dist/runner.js ADDED
@@ -0,0 +1,45 @@
1
+ import { resolveCompare } from "./compare.js";
2
+ export async function runDriftCheck(check, configDir) {
3
+ const warnings = [];
4
+ const ctx = { warn: (m) => warnings.push(m), configDir };
5
+ const started = Date.now();
6
+ const done = (outcome, messages) => ({
7
+ name: check.name,
8
+ outcome,
9
+ messages,
10
+ warnings,
11
+ checkedAt: new Date(started).toISOString(),
12
+ durationMs: Date.now() - started,
13
+ });
14
+ let expected;
15
+ try {
16
+ expected = typeof check.expect === 'function' ? await check.expect(ctx) : check.expect;
17
+ }
18
+ catch (err) {
19
+ // A broken expectation is consumer misconfiguration, not source rot or
20
+ // source outage — surface it as `error` so CI warns loudly.
21
+ return done('error', [`expect failed: ${err instanceof Error ? err.message : String(err)}`]);
22
+ }
23
+ let actual;
24
+ try {
25
+ actual = await check.source(ctx);
26
+ }
27
+ catch (err) {
28
+ return done('error', [`source failed: ${err instanceof Error ? err.message : String(err)}`]);
29
+ }
30
+ try {
31
+ const mismatches = resolveCompare(check.compare)(expected, actual);
32
+ return mismatches.length ? done('rot', mismatches) : done('pass', []);
33
+ }
34
+ catch (err) {
35
+ return done('error', [`compare failed: ${err instanceof Error ? err.message : String(err)}`]);
36
+ }
37
+ }
38
+ export async function runDrift(config, options) {
39
+ const checks = (config.drift ?? []).filter((c) => !options.only || options.only.includes(c.name));
40
+ const results = [];
41
+ for (const check of checks) {
42
+ results.push(await runDriftCheck(check, options.configDir));
43
+ }
44
+ return results;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stilltrue",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Your tests check your code. stilltrue checks your facts. CI checks for AI-in-the-loop apps: drift (curated facts vs live sources), golden (regression evals), verify (judge answers against sources).",
5
5
  "keywords": [
6
6
  "drift",
@@ -25,9 +25,10 @@
25
25
  "scripts": {
26
26
  "build": "tsc",
27
27
  "dev": "tsc --watch",
28
- "test": "node --test --experimental-strip-types test/*.test.ts"
28
+ "test": "node --test test/*.test.ts"
29
29
  },
30
30
  "dependencies": {
31
+ "jiti": "^2.7.0",
31
32
  "zod": "^3.23.0"
32
33
  },
33
34
  "devDependencies": {