From 16a28b307b63041ddf45072de22df7e3dc64af92 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 17:27:22 -0400 Subject: [PATCH 01/21] docs(spec): config editor workbench design (CM6 + live impact + wiki save-back) Design spec for the in-app config editor (Observatory workbench), answering FoxxMD's feedback that raw-wiki editing is too much friction for mods. v1: CodeMirror 6 editor in Devvit expanded mode, YAML-first (JSON5 too), schema autocomplete + hover + inline AJV validation, live rule-impact simulation, AI explainer, config diff, and save-back to the wiki via updateWikiPage. Monaco ruled out (Devvit webview CSP blocks runtime code evaluation and external client fetch). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-27-config-editor-design.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-config-editor-design.md diff --git a/docs/superpowers/specs/2026-05-27-config-editor-design.md b/docs/superpowers/specs/2026-05-27-config-editor-design.md new file mode 100644 index 0000000..266d212 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-config-editor-design.md @@ -0,0 +1,141 @@ +# ContextMod Observatory: In-App Config Editor (Workbench) — Design Spec + +- Date: 2026-05-27 +- Status: Approved for planning +- Source feedback: FoxxMD (upstream ContextMod author), Discord 2026-05-27 + +## 1. Problem + +Today a mod edits the rule config only in Reddit's raw wiki page (`r//wiki/botconfig/contextmod`). The Observatory dashboard just links out to it (`src/client/components/ActionBar.tsx:122`). No syntax help, no validation, no save-back. FoxxMD flagged this as the top friction point: "in-place editing and syntax validation are definitely the most crucial components." Non-technical mods must copy/paste between windows and match whitespace exactly. + +## 2. Goal + +An in-app config editor inside the Observatory dashboard that loads the current wiki config, edits it with syntax and schema help, validates inline, previews real moderation impact, and saves back to the wiki. No copy/paste. No external hosting. CSP-safe. + +## 3. Scope (v1 = full feature set) + +In: +- Expanded-mode workbench (`requestExpandedMode`) with split layout. +- CodeMirror 6 editor: YAML default, JSON5 supported, auto-detected. +- Schema-driven autocomplete + hover docs + inline AJV validation (squiggles + gutter). +- Live Impact pane: rule simulation against a cached sample of the sub's recent items, updating on debounced edit. +- AI Explain pane: plain-English summary of the current config (reuse `explainRule`). +- Diff pane: edited config vs current published rev (reuse `ConfigDiffViewer`). +- Save-back to wiki via `updateWikiPage`, server-validated, with optimistic-lock conflict guard. + +Non-goals (deferred): +- Operator-level vs subreddit-level schema toggle (not needed for the per-sub Devvit model). +- Removal-reasons helper, multi-wiki-page editing, real-time multi-user collaboration. + +## 4. Key decisions + rationale + +- Editor is CodeMirror 6, not monaco. Monaco hard-requires `unsafe-eval`: its language workers bootstrap by evaluating code strings at runtime, and it loads those workers from external URLs. Devvit webviews block client-side external fetch and do not guarantee `unsafe-eval`. Monaco is also multi-MB. CM6 core is free of runtime code evaluation (verified against published bundles), worker-free, roughly 120 to 180 KB gzipped, and `codemirror-json-schema` provides schema autocomplete + hover + lint for YAML and JSON. Net: the monaco-style experience delivered CSP-safe. +- Surface is `requestExpandedMode` (Devvit-native: fullscreen on mobile, large modal on web), triggered on click. Replaces FoxxMD's new-tab pattern, which depends on his external host. Keeps the no-hosting model. +- Format is YAML default, JSON5 also supported, auto-detected via the existing `sniffFormat`. Rationale (FoxxMD): AutoMod uses YAML, so mods transfer the skill. The parser already accepts both. +- Reuse, do not rebuild: `simulateRule`, `explainRule`, `ConfigDiffViewer`, `parseConfig` + AJV, `app.schema.json`, `configStore.publish`, `loadFromWiki` all already exist. + +## 5. Architecture + +### 5.1 Surface + layout + +`ActionBar` gets an "Edit config" button. On click, the client calls `requestExpandedMode` (from `@devvit/web/client`) and mounts `ConfigWorkbench`. Layout: left is the editor, right is a tabbed preview (Impact / Explain / Diff). Top bar: format indicator, validity indicator (valid or N errors), Validate, and Save to r/. + +### 5.2 Client components (new + reused) + +- `ConfigWorkbench.tsx` (new): orchestrator; lazy-loaded via dynamic import so CM6 is not in the dashboard's initial bundle. +- `ConfigEditor.tsx` (new): CM6 wrapper. Extensions: lang-yaml + lang-json, `codemirror-json-schema` (schema is `app.schema.json`), lint (AJV-backed), `EditorView.cspNonce`. +- `PreviewPane.tsx` (new): tabs. + - ImpactTab: per-rule "would fire on N/M recent items" + example thingIds. + - ExplainTab: AI explanation (reuse `explainRule`). + - DiffTab: reuse `ConfigDiffViewer` (edited text vs current rev). +- api client (`src/client/lib`): add raw / save / simulate-live calls. + +### 5.3 Server endpoints (`src/routes/api.ts`, new) + +- `GET /api/config/raw`: returns `{ content, revisionId, format }` via the existing wiki read (`reddit.getWikiPage`, `src/core/configSource.ts:64`). +- `POST /api/config/validate`: body `{ text }`; runs `parseConfig` + AJV; returns `{ ok, errors }`. The server validate is the authority; the client may also run AJV for instant feedback. +- `POST /api/config/simulate-live`: body `{ text }`; parses, then runs the existing dry-run `simulateRule` against a cached sample of recent items; returns per-rule fire counts + examples. Dry-run only, zero side effects. +- `POST /api/config/save`: body `{ text, baseRevisionId }`; server re-parses + AJV-validates (reject if invalid); re-reads the current wiki `revisionId`, returns 409 if it differs from `baseRevisionId` (optimistic lock); else `reddit.updateWikiPage({ subredditName, page: WIKI_PAGE, content: text, reason })`; then publish + reload; returns `{ rev, ruleCount }`. + +### 5.4 Live Impact sampling (the looks-impossible part, made cheap) + +On workbench open, fetch ONE sample of recent items (for example the last 25 posts plus recent comments) via the existing read path, normalize, and cache (Redis, short TTL, keyed by sub + session). On each debounced valid edit (about 700 ms), `simulate-live` re-runs the proposed rules against the CACHED sample only. No repeated Reddit fetches, pure CPU, so live updates are fast and stay within API limits. It uses the dry-run simulation path, so no real moderation actions ever fire. + +### 5.5 Save safety (writes to the live moderation config) + +- Server-side re-validation is the gate: a config that fails AJV is never written to the wiki. +- Optimistic lock: save includes the `revisionId` the editor loaded; if the wiki changed since (concurrent edit), return 409 and prompt reload. Reuses the `cfgLastWikiRev` concept. +- Write as the app account (existing moderator scope). The `reason` string records the acting mod for the wiki audit log. +- Auth: save + simulate endpoints verify the caller is a moderator of the sub (reuse the app's existing mod-auth pattern, defense-in-depth per the prior review wave). +- The wiki retains revision history and `configStore` keeps `cfg:rev` snapshots, so revert is restoring a prior rev (the diff viewer shows what changed). + +### 5.6 Schema hover docs + +`app.schema.json` currently has no `description` fields. Add them so `codemirror-json-schema` shows hover documentation. Source the text from the JSDoc already on the interfaces in `src/shared/types.ts`. Prefer generating the schema (or its descriptions) from the types via `ts-json-schema-generator` to keep them in sync; if generation is heavy, hand-add descriptions for the common rule / action / filter properties in v1. + +## 6. Data flow (end to end) + +1. Open: click Edit config, call `requestExpandedMode`, lazy-load `ConfigWorkbench`, `GET /api/config/raw`, populate CM6, stamp `baseRevisionId`. Kick off one sample fetch for Impact. +2. Edit: keystrokes, CM6 + schema autocomplete/hover, debounced AJV lint (squiggles), validity indicator updates. +3. Impact: on debounced valid edit, `POST /api/config/simulate-live` (cached sample), ImpactTab renders fire-rate + examples. +4. Explain / Diff: on demand per tab. +5. Save: `POST /api/config/save`, server validate + lock check, `updateWikiPage`, publish + reload, toast "Saved, N rules live (rev R)". On 409, "Wiki changed, reload to merge". + +## 7. Error handling + edge cases + +- Invalid config: lint shows errors; Save disabled; server double-checks and rejects. +- Wiki read fails (404 fresh install, or breaker open): editor opens with the default-config template (`src/config/default-config.ts`) and a note. +- Save conflict (409): prompt reload; do not overwrite. +- Empty or first config: seed the editor with the default template; offer the YAML variant given AutoMod parity. +- Reddit API hiccup on save: surface the error, do not mark saved, config unchanged. +- CSP: pass `EditorView.cspNonce`; verify in playtest. +- Large config: CM6 handles large docs; lint debounced. + +## 8. Bundle + performance + +- Lazy-load the workbench (dynamic import) so the dashboard initial bundle stays near its current size (about 120 KB); CM6 (about 120 to 180 KB gzipped) loads only when Edit config is clicked. +- Tree-shake CM6 to the needed packages (state, view, language, lang-yaml, lang-json, lint, autocomplete, `codemirror-json-schema`, ajv). +- The sample is cached so live simulation is CPU-only after the first fetch. + +## 9. Security + +- Wiki-write is a new privileged surface. Gate every save behind a server-side mod-auth check plus server-side AJV validation. Never trust client-validated input. +- `updateWikiPage` runs as the app account (moderator scope already declared). Confirm the app's mod permissions include wiki edit at install (build-phase verification). +- Dry-run only for simulation; no real actions from the editor. +- The `reason` string on the wiki write records the acting mod for audit. + +## 10. Testing + +- Unit (vitest): `/api/config/raw` read; `/api/config/save` (rejects invalid, 409 on rev mismatch, success path calls `updateWikiPage` + publish); `simulate-live` reuses `simulateRule` + cached sample + dry-run; auth gate rejects non-mods. +- Client: `ConfigEditor` mounts CM6 and surfaces AJV lint; `PreviewPane` tab switching; Save disabled when invalid. +- E2E (Playwright, `?demo=1` with a mock wiki): open workbench, type invalid YAML (error + Save disabled), type valid (Impact shows fire-rate), Save (toast). axe-core scan in light + dark. +- Reuse the existing test harness and patterns. + +## 11. Build-phase verifications (must pass before ship) + +- CM6 renders inside a real Devvit playtest webview; determine whether `style-src` needs the nonce; confirm no runtime code-evaluation path is hit. +- App account has wiki-EDIT permission for `updateWikiPage` at install. +- Bundle budget: dashboard initial load unchanged; workbench chunk acceptable. + +## 12. Build order within v1 + +1. Server: `/api/config/raw` + `/api/config/save` (validate gate + `updateWikiPage` + publish + 409 lock + mod-auth). Wiki-edit perm. +2. Client shell: ActionBar button + `requestExpandedMode` + `ConfigWorkbench` + `ConfigEditor` (CM6 YAML/JSON5 syntax) + load/save + lazy-load. +3. Schema help: descriptions on `app.schema.json` (from types JSDoc) + `codemirror-json-schema` autocomplete/hover/lint. +4. Preview: Diff tab (reuse), Explain tab (reuse), Impact tab (cached sample + live dry-run simulate). +5. Polish: validity + conflict UX, default-config seeding, mobile, accessibility. + +## 13. Traceability to FoxxMD's asks + +| FoxxMD ask | Delivered by | +|---|---| +| in-place editing (crucial) | `ConfigWorkbench` + CM6 in expanded mode | +| syntax validation (crucial) | `codemirror-json-schema` lint + AJV, inline | +| load + save wiki, no copy/paste | `/api/config/raw` + `/api/config/save` (`updateWikiPage`) | +| YAML (AutoMod parity) | YAML default, JSON5 also | +| monaco bells-and-whistles | CM6 schema autocomplete + hover (CSP-safe) | +| exceeds his editor | live Impact simulation + AI explain + diff | + +## 14. Open questions + +None blocking. Decide at build time: generate schema descriptions from JSDoc versus hand-author (lean toward generate). From 9700f4d1b0b7ee5a498c35e2764fbbd034b7d896 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 17:51:58 -0400 Subject: [PATCH 02/21] docs(plan): config editor workbench v1 implementation plan (17 tasks, TDD) Bite-sized TDD plan with real code for all 17 tasks across 5 phases: server endpoints (raw/validate/simulate-live/explain/save with validation gate + optimistic lock), CodeMirror 6 editor + schema hints, live Impact + AI Explain + Diff preview, and Devvit build-phase verifications. Reuses simulateRule, explainRule, parseConfig+AJV, configStore, ConfigDiffViewer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-27-config-editor.md | 1355 +++++++++++++++++ 1 file changed, 1355 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-config-editor.md diff --git a/docs/superpowers/plans/2026-05-27-config-editor.md b/docs/superpowers/plans/2026-05-27-config-editor.md new file mode 100644 index 0000000..dd74e7b --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-config-editor.md @@ -0,0 +1,1355 @@ +# Config Editor (Observatory Workbench) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-app config editor to the ContextMod Observatory dashboard so mods load, edit (with YAML/JSON syntax + schema hints + inline validation), preview live impact, and save back to the wiki, with no copy/paste. + +**Architecture:** A new server surface adds read/validate/simulate/explain/save endpoints to the existing Hono `api` app, reusing `parseConfig` + AJV, `simulateRule`, `explainRule`, and `configStore.publish`. The client adds a lazy-loaded `ConfigWorkbench` opened via Devvit `requestExpandedMode`, built on CodeMirror 6 + `codemirror-json-schema` (CSP-safe, monaco is not viable on Devvit). Save-back uses `reddit.updateWikiPage`, gated by server-side re-validation + an optimistic lock. + +**Tech Stack:** TypeScript, Hono, React 18, Vite, CodeMirror 6, `codemirror-json-schema`, AJV, json5, js-yaml, Devvit Web, Vitest, Playwright. + +**Spec:** `docs/superpowers/specs/2026-05-27-config-editor-design.md` + +--- + +## Conventions (apply to every task) + +- Branch: `feat/config-editor` (already checked out). +- Test: `npm test` (alias for `vitest run --config vitest.config.ts`). Single file: `npm test -- `. +- Type-check: `npm run type-check`. Lint: `npm run lint`. Build: `npm run build`. +- Commit messages: Conventional Commits, subject <= 100 chars, no em-dash, end body with: + `Co-Authored-By: Claude Opus 4.7 (1M context) ` +- Test mocking pattern (mirror `tests/routes/forms-simulate-rule.test.ts`): `vi.mock('@devvit/web/server', ...)` and `vi.mock('../../src/lib/requireModerator', ...)` BEFORE importing the Hono app, then `await api.request(new Request('http://x/', {...}))`. +- Existing reused symbols: `WIKI_PAGE` + `loadFromWiki` (`src/core/configSource.ts`), `parseConfig` (`src/core/config.ts`), `publish` + `getCurrentRev` + `getRecentRevs` (`src/state/configStore.ts`), `requireModerator` (`src/lib/requireModerator.ts`, returns `{ok:true,sub,username} | {ok:false,status,error}`), `simulateRule` + `SimulationSample` (`src/core/simulateRule.ts`), `explainRule` (`src/core/explainRule.ts`), `checkRateLimit` (`src/lib/ratelimit`), `logModActivity` (`src/state/modActivity`), `K` (`src/state/keys.ts`). + +--- + +## File Structure + +New: +- `src/lib/resolveOpenaiKey.ts` — shared OpenAI key resolver (extracted from `forms.ts`). +- `src/core/recentSample.ts` — fetch + normalize + cache the sub's recent items as `SimulationSample[]`. +- `src/routes/configEditor.ts` — the new `/api/config/*` route group (raw, validate, simulate-live, explain, save). +- `src/client/components/ConfigEditor.tsx` — CodeMirror 6 wrapper. +- `src/client/components/ConfigWorkbench.tsx` — orchestrator (load, edit, preview, save), lazy-loaded. +- `src/client/components/PreviewPane.tsx` — Impact / Explain / Diff tabs. +- `scripts/gen-schema-descriptions.mjs` — inject `description` fields into `app.schema.json` from `src/shared/types.ts` JSDoc. + +Modified: +- `src/routes/api.ts` — mount the config-editor routes. +- `src/routes/forms.ts` — use the extracted `resolveOpenaiKey` + `getRecentSample`. +- `src/client/lib/api.ts` — add `fetchConfigRawSafe`, `validateConfigSafe`, `saveConfigSafe`, `simulateLiveSafe`, `explainConfigSafe`. +- `src/client/lib/types.ts` — add wire types. +- `src/client/components/ActionBar.tsx` — add "Edit config" button + `onEditConfig` prop. +- `src/client/App.tsx` — `editorOpen` state + lazy `` + Escape close. +- `src/schema/app.schema.json` — add `description` fields. +- `package.json` — add CodeMirror deps. + +--- + +## PHASE 1 — Server (read, validate, simulate, explain, save) + +### Task 1: Extract `resolveOpenaiKey` (DRY prep) + +**Files:** +- Create: `src/lib/resolveOpenaiKey.ts` +- Modify: `src/routes/forms.ts` (replace the local `resolveOpenaiKey`) +- Test: `tests/lib/resolveOpenaiKey.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/lib/resolveOpenaiKey.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getOpenaiKey = vi.fn(); +const settingsGet = vi.fn(); +vi.mock('../../src/state/apiKeyStore', () => ({ getOpenaiKey: (s: string) => getOpenaiKey(s) })); +vi.mock('@devvit/web/server', () => ({ settings: { get: (k: string) => settingsGet(k) } })); + +import { resolveOpenaiKey } from '../../src/lib/resolveOpenaiKey'; + +describe('resolveOpenaiKey', () => { + beforeEach(() => { getOpenaiKey.mockReset(); settingsGet.mockReset(); }); + + it('prefers the Redis key', async () => { + getOpenaiKey.mockResolvedValue('sk-redis'); + expect(await resolveOpenaiKey('sub')).toBe('sk-redis'); + }); + + it('falls back to settings, trimmed', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(' sk-settings '); + expect(await resolveOpenaiKey('sub')).toBe('sk-settings'); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/lib/resolveOpenaiKey.test.ts` +Expected: FAIL ("Cannot find module '../../src/lib/resolveOpenaiKey'"). + +- [ ] **Step 3: Implement** + +```ts +// src/lib/resolveOpenaiKey.ts +import { settings } from '@devvit/web/server'; +import { getOpenaiKey } from '../state/apiKeyStore'; + +/** Resolve the OpenAI key for a sub: encrypted-Redis key first, then the + * plaintext Devvit subreddit-setting fallback. Returns '' when neither set. */ +export async function resolveOpenaiKey(sub: string): Promise { + const fromRedis = await getOpenaiKey(sub); + if (fromRedis) return fromRedis; + return ((await settings.get('openai_api_key')) ?? '').trim(); +} +``` + +- [ ] **Step 4: Refactor `forms.ts`** — delete its local `resolveOpenaiKey` (the agent located it near line 46) and add `import { resolveOpenaiKey } from '../lib/resolveOpenaiKey';`. Leave call sites unchanged. + +- [ ] **Step 5: Run tests + type-check** + +Run: `npm test -- tests/lib/resolveOpenaiKey.test.ts && npm run type-check` +Expected: PASS, tsc clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/resolveOpenaiKey.ts src/routes/forms.ts tests/lib/resolveOpenaiKey.test.ts +git commit -m "refactor: extract resolveOpenaiKey into a shared lib" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 2: Extract + cache the recent-items sample (`getRecentSample`) + +**Files:** +- Create: `src/core/recentSample.ts` +- Modify: `src/routes/forms.ts` (use `getRecentSample` in the simulate-rule handler) +- Test: `tests/core/recentSample.test.ts` + +The simulate-rule form currently builds samples inline (`fetchRecentPostsSafe` + a `normalizePost` loop). Extract that into a cached helper so the editor's live-impact endpoint reuses it without re-fetching Reddit on every keystroke. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/core/recentSample.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getNewPosts = vi.fn(); +const redisGet = vi.fn(); +const redisSet = vi.fn(); +vi.mock('@devvit/web/server', () => ({ + reddit: { getNewPosts: (o: unknown) => getNewPosts(o) }, + redis: { get: (k: string) => redisGet(k), set: (k: string, v: string, o?: unknown) => redisSet(k, v, o) }, +})); + +import { getRecentSample } from '../../src/core/recentSample'; + +describe('getRecentSample', () => { + beforeEach(() => { getNewPosts.mockReset(); redisGet.mockReset(); redisSet.mockReset(); }); + + it('returns the cached sample without hitting reddit', async () => { + redisGet.mockResolvedValue(JSON.stringify([{ item: { id: 't3_x' }, author: { name: 'a' } }])); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect(getNewPosts).not.toHaveBeenCalled(); + }); + + it('fetches + caches on a cache miss', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ all: async () => [] }); + await getRecentSample('sub'); + expect(getNewPosts).toHaveBeenCalledOnce(); + expect(redisSet).toHaveBeenCalledOnce(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/core/recentSample.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** (move the `fetchRecentPostsSafe` + `normalizePost` logic out of `forms.ts`; reuse the existing `normalizePost` import there) + +```ts +// src/core/recentSample.ts +import { reddit, redis } from '@devvit/web/server'; +import type { SimulationSample } from './simulateRule'; +import { normalizePost } from './normalize'; // same module forms.ts imports normalizePost from +import { getCurrentRev } from '../state/configStore'; +import type { AppConfig, PostSubmitPayload } from '../shared/types'; + +const SAMPLE_LIMIT = 25; +const CACHE_TTL_MS = 60_000; // 60s: fresh enough for live preview, no per-keystroke fetch + +function cacheKey(sub: string) { return `cm:${sub}:editor:sample`; } + +/** Recent posts of the sub, normalized to SimulationSample[], cached 60s. */ +export async function getRecentSample(sub: string): Promise { + try { + const cached = await redis.get(cacheKey(sub)); + if (cached) return JSON.parse(cached) as SimulationSample[]; + } catch { + /* cache read miss/fail -> fall through to fetch */ + } + + const snapshot = await getCurrentRev(sub); + const config: AppConfig = snapshot?.config ?? { runs: [], needsAuthorEnrichment: false }; + + const redditAny = reddit as unknown as { getNewPosts: (o: unknown) => { all: () => Promise } }; + const listing = redditAny.getNewPosts({ subredditName: sub, limit: SAMPLE_LIMIT, pageSize: SAMPLE_LIMIT }); + const posts = (await listing.all()).slice(0, SAMPLE_LIMIT) as Array>; + + const samples: SimulationSample[] = []; + for (const p of posts) { + const payload = { post: p, author: { name: (p as { authorName?: string }).authorName } } as unknown as PostSubmitPayload; + const normalized = await normalizePost(payload, config); + samples.push({ item: normalized.item, author: normalized.author }); + } + + try { + await redis.set(cacheKey(sub), JSON.stringify(samples), { expiration: new Date(Date.now() + CACHE_TTL_MS) }); + } catch { + /* cache write fail is non-fatal */ + } + return samples; +} +``` + +NOTE for the implementer: open `src/routes/forms.ts` and copy the exact `normalizePost` import path + the exact `getNewPosts` option object it uses; match them here so the normalization is identical. The payload shape above is the structural form the existing handler builds. + +- [ ] **Step 4: Refactor `forms.ts`** — replace its inline sample-building in `/simulate-rule-submit` with `const samples = await getRecentSample(sub.name);`. Keep the `simulateRule(ruleJson5, samples, sub.name)` call. + +- [ ] **Step 5: Run tests + type-check** + +Run: `npm test -- tests/core/recentSample.test.ts tests/routes/forms-simulate-rule.test.ts && npm run type-check` +Expected: PASS (the existing forms test still passes against the refactor). + +- [ ] **Step 6: Commit** + +```bash +git add src/core/recentSample.ts src/routes/forms.ts tests/core/recentSample.test.ts +git commit -m "refactor: extract cached getRecentSample for reuse by the editor" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 3: Config-editor route group + `GET /api/config/raw` + +**Files:** +- Create: `src/routes/configEditor.ts` +- Modify: `src/routes/api.ts` (mount the sub-app) +- Test: `tests/routes/config-editor.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/routes/config-editor.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getWikiPage = vi.fn(); +const requireModeratorMock = vi.fn(); +vi.mock('@devvit/web/server', () => ({ + reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, + redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK') }, + settings: { get: vi.fn() }, +})); +vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); + +import { configEditor } from '../../src/routes/configEditor'; + +const MOD = { ok: true, sub: 'testsub', username: 'mod1' }; +const NON_MOD = { ok: false, status: 403, error: 'not a moderator of this sub' }; + +async function get(path: string) { + const res = await configEditor.request(new Request(`http://x${path}`)); + return { status: res.status, body: await res.json() }; +} + +describe('GET /raw', () => { + beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); + + it('returns wiki content + revisionId for a mod', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-1' }); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ content: 'runs: []', revisionId: 'rev-1' }); + }); + + it('rejects non-mods', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await get('/raw'); + expect(r.status).toBe(403); + }); + + it('returns the default template on a missing page', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('404 not found')); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body.isDefaultTemplate).toBe(true); + expect(typeof r.body.content).toBe('string'); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** + +Run: `npm test -- tests/routes/config-editor.test.ts` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement the route group + `/raw`** + +```ts +// src/routes/configEditor.ts +import { Hono } from 'hono'; +import { reddit } from '@devvit/web/server'; +import { WIKI_PAGE } from '../core/configSource'; +import { requireModerator } from '../lib/requireModerator'; +import { DEFAULT_CONFIG_YAML } from '../config/default-config'; // see NOTE below +import { log } from '../lib/log'; + +export const configEditor = new Hono(); + +function isNotFound(err: unknown): boolean { + const m = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return m.includes('not found') || m.includes('404') || m.includes('does not exist'); +} + +configEditor.get('/raw', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ error: auth.error }, auth.status); + try { + const page = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + return c.json({ content: page.content, revisionId: page.revisionId, isDefaultTemplate: false }); + } catch (err) { + if (isNotFound(err)) { + return c.json({ content: DEFAULT_CONFIG_YAML, revisionId: null, isDefaultTemplate: true }); + } + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/raw', 'wiki read failed', { err: msg, sub: auth.sub }); + return c.json({ error: `wiki unavailable: ${msg}` }, 503); + } +}); +``` + +NOTE for the implementer: `src/config/default-config.ts` currently exports a JSON5 default. Add a `DEFAULT_CONFIG_YAML` export (the same starter config rendered as YAML, since YAML is the default editor format). If a YAML constant is awkward to maintain by hand, import the existing default object and `import { dump } from 'js-yaml'` to render it: `export const DEFAULT_CONFIG_YAML = dump(DEFAULT_CONFIG_OBJECT);`. + +- [ ] **Step 4: Mount in `api.ts`** + +In `src/routes/api.ts`, add near the other imports: `import { configEditor } from './configEditor';` and after `export const api = new Hono();` add: `api.route('/config', configEditor);` + +- [ ] **Step 5: Run tests + type-check + lint** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check && npm run lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/routes/configEditor.ts src/routes/api.ts src/config/default-config.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): GET /api/config/raw returns wiki text + revisionId" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 4: `POST /api/config/validate` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` (add a describe block) + +- [ ] **Step 1: Write the failing test** + +```ts +// add to tests/routes/config-editor.test.ts +async function post(path: string, body: unknown) { + const res = await configEditor.request(new Request(`http://x${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +describe('POST /validate', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); }); + it('accepts a valid config', async () => { + const r = await post('/validate', { text: 'runs: []' }); + expect(r.body.ok).toBe(true); + }); + it('reports errors for an invalid config', async () => { + const r = await post('/validate', { text: 'runs: "not an array"' }); + expect(r.body.ok).toBe(false); + expect(r.body.errors).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run it, expect fail** — Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL ("/validate" 404). + +- [ ] **Step 3: Implement** + +```ts +// add to src/routes/configEditor.ts +import { parseConfig } from '../core/config'; + +configEditor.post('/validate', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string') return c.json({ ok: false, error: 'text required' }, 400); + const parsed = parseConfig(text); + if (parsed.ok) return c.json({ ok: true, format: parsed.format }); + return c.json({ ok: false, errors: parsed.errors }); +}); +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check` +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/validate runs parseConfig + AJV" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 5: `POST /api/config/simulate-live` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +- [ ] **Step 1: Write the failing test** (mock `getRecentSample` + `simulateRule`) + +```ts +// add near the other vi.mock calls in tests/routes/config-editor.test.ts +const getRecentSample = vi.fn(); +const simulateRule = vi.fn(); +vi.mock('../../src/core/recentSample', () => ({ getRecentSample: (s: string) => getRecentSample(s) })); +vi.mock('../../src/core/simulateRule', () => ({ simulateRule: (...a: unknown[]) => simulateRule(...a) })); + +describe('POST /simulate-live', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); getRecentSample.mockResolvedValue([]); }); + it('returns the simulation result', async () => { + simulateRule.mockResolvedValue({ ok: true, totalSamples: 25, firedCount: 7, erroredCount: 0, breakdown: [] }); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, firedCount: 7, totalSamples: 25 }); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** (rate-limited; dry-run via `simulateRule`, which never executes actions) + +```ts +// add to src/routes/configEditor.ts +import { getRecentSample } from '../core/recentSample'; +import { simulateRule } from '../core/simulateRule'; +import { checkRateLimit } from '../lib/ratelimit'; + +configEditor.post('/simulate-live', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + // Per-sub limit: live preview fires on debounced edits; cap to protect Reddit-API budget. + const rl = await checkRateLimit('simulate-live', auth.sub, 120, 60); + if (!rl.allowed) return c.json({ ok: false, error: 'Slow down a moment, then keep editing.' }, 429); + + const samples = await getRecentSample(auth.sub); + const result = await simulateRule(text, samples, auth.sub); + return c.json(result); +}); +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check` +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/simulate-live (dry-run impact on cached sample)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 6: `POST /api/config/explain` + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +Mirror the cost controls already on `/api/explain-event` (breaker + per-sub + per-user rate limit). Reuse `resolveOpenaiKey` (Task 1) + `explainRule`. + +- [ ] **Step 1: Write the failing test** + +```ts +const explainRule = vi.fn(); +const resolveOpenaiKey = vi.fn(); +vi.mock('../../src/core/explainRule', () => ({ explainRule: (...a: unknown[]) => explainRule(...a) })); +vi.mock('../../src/lib/resolveOpenaiKey', () => ({ resolveOpenaiKey: (s: string) => resolveOpenaiKey(s) })); + +describe('POST /explain', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); resolveOpenaiKey.mockResolvedValue('sk-x'); }); + it('returns the explanation', async () => { + explainRule.mockResolvedValue({ ok: true, value: 'This rule removes crypto spam.' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, explanation: 'This rule removes crypto spam.' }); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** (rate-limit cost gate, fail-closed on degraded as the explain-event route does) + +```ts +// add to src/routes/configEditor.ts +import { explainRule } from '../core/explainRule'; +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; + +configEditor.post('/explain', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + const rl = await checkRateLimit('explain', `${auth.sub}:${auth.username}`, 10, 3600); + if (rl.degraded) return c.json({ ok: false, error: 'Rate-limit subsystem degraded. Retry in ~60s.' }, 503); + if (!rl.allowed) return c.json({ ok: false, error: `Your limit: ${rl.count}/${rl.max} this hour.` }, 429); + + const apiKey = await resolveOpenaiKey(auth.sub); + if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); + const result = await explainRule(text, apiKey); + if (!result.ok) return c.json({ ok: false, error: result.error }, 500); + return c.json({ ok: true, explanation: result.value }); +}); +``` + +- [ ] **Step 4: Run + commit** + +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/explain (AI explainer for the editor)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 7: `POST /api/config/save` (the privileged write) + +**Files:** +- Modify: `src/routes/configEditor.ts` +- Test: `tests/routes/config-editor.test.ts` + +Order of operations: mod-auth, re-validate (reject invalid), optimistic-lock check (re-read current revisionId, 409 if it moved since the editor loaded), `updateWikiPage`, then `publish` + stamp `cfgLastWikiRev`, then `logModActivity`. + +- [ ] **Step 1: Write the failing tests** (invalid -> 400, conflict -> 409, success -> updateWikiPage + publish called) + +```ts +const updateWikiPage = vi.fn(); +const publish = vi.fn(); +const logModActivity = vi.fn(); +// extend the @devvit/web/server mock's reddit with updateWikiPage already declared in Task 3 mock. +vi.mock('../../src/state/configStore', () => ({ + publish: (...a: unknown[]) => publish(...a), + getCurrentRev: vi.fn(), + getRecentRevs: vi.fn(), +})); +vi.mock('../../src/state/modActivity', () => ({ logModActivity: (...a: unknown[]) => logModActivity(...a) })); + +describe('POST /save', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + updateWikiPage.mockReset(); publish.mockReset(); getWikiPage.mockReset(); + }); + + it('rejects an invalid config without writing', async () => { + const r = await post('/save', { text: 'runs: "bad"', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(400); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('409s when the wiki moved since load', async () => { + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-2' }); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(409); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('saves a valid config and publishes', async () => { + getWikiPage.mockResolvedValue({ content: 'old', revisionId: 'rev-1' }); + publish.mockResolvedValue(0); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(200); + expect(updateWikiPage).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + }); +}); +``` + +NOTE: in the `@devvit/web/server` mock from Task 3, change `updateWikiPage: vi.fn()` to `updateWikiPage: (o: unknown) => updateWikiPage(o)` and declare `const updateWikiPage = vi.fn();` at the top so the assertion can see it. + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/routes/config-editor.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** + +```ts +// add to src/routes/configEditor.ts +import { publish } from '../state/configStore'; +import { redis } from '@devvit/web/server'; +import { K } from '../state/keys'; +import { logModActivity } from '../state/modActivity'; + +configEditor.post('/save', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text, baseRevisionId } = await c.req.json<{ text?: string; baseRevisionId?: string | null }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + + // Gate 1: never write an invalid config to the live moderation wiki. + const parsed = parseConfig(text); + if (!parsed.ok) return c.json({ ok: false, error: 'config invalid', errors: parsed.errors }, 400); + + // Gate 2: optimistic lock. Re-read the current wiki rev; if it moved since the + // editor loaded, refuse so we never silently clobber a concurrent edit. + try { + const current = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + if (baseRevisionId && current.revisionId !== baseRevisionId) { + return c.json({ ok: false, error: 'The wiki changed since you opened the editor. Reload to merge.', conflict: true }, 409); + } + } catch (err) { + if (!isNotFound(err)) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Could not verify current wiki state: ${msg}` }, 503); + } + // not-found = first save on a fresh sub; allow the create. + } + + // Write, then publish the parsed snapshot + stamp the wiki rev so the 5-min cron + // does not re-publish an identical config. + try { + await reddit.updateWikiPage({ + subredditName: auth.sub, + page: WIKI_PAGE, + content: text, + reason: `Edited via ContextMod Observatory by u/${auth.username}`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/save', 'updateWikiPage failed', { err: msg, sub: auth.sub }); + return c.json({ ok: false, error: `Wiki write failed: ${msg}` }, 502); + } + + const rev = await publish(parsed.config, auth.sub); + try { + const after = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + await redis.set(K.cfgLastWikiRev(auth.sub), after.revisionId); + } catch { + /* stamping is best-effort; the cron self-heals next tick */ + } + const ruleCount = parsed.config.runs.flatMap((r) => r.checks).flatMap((ch) => ch.rules).length; + await logModActivity(auth.sub, { ts: Date.now(), actor: auth.username, kind: 'edit-config', detail: `${ruleCount} rules @ rev ${rev}` }); + return c.json({ ok: true, rev, ruleCount }); +}); +``` + +NOTE: confirm `publish`'s return value (the agent reported `publish(config, sub)` allocating a rev; if it returns void, drop `rev` from the response and read it via `getCurrentRev`). Confirm `K.cfgLastWikiRev` exists in `src/state/keys.ts` (the agent confirmed it does). + +- [ ] **Step 4: Run tests + type-check + lint** + +Run: `npm test -- tests/routes/config-editor.test.ts && npm run type-check && npm run lint` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/configEditor.ts tests/routes/config-editor.test.ts +git commit -m "feat(api): POST /api/config/save with validation gate + optimistic lock" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 2 — Client shell + editor + +### Task 8: Add CodeMirror dependencies + +**Files:** Modify `package.json` + `package-lock.json`. + +- [ ] **Step 1: Install** (pinned, CSP-safe set) + +```bash +npm install @codemirror/state@^6 @codemirror/view@^6 @codemirror/commands@^6 @codemirror/language@^6 @codemirror/lang-yaml@^6 @codemirror/lang-json@^6 @codemirror/lint@^6 @codemirror/autocomplete@^6 codemirror-json-schema@^0.8 +``` + +- [ ] **Step 2: Verify it builds + type-checks** + +Run: `npm run type-check && npm run build` +Expected: clean (deps resolve; no usage yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "build: add CodeMirror 6 + codemirror-json-schema deps" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 9: Client API helpers + wire types + +**Files:** +- Modify: `src/client/lib/api.ts`, `src/client/lib/types.ts` +- Test: `tests/client/config-api.test.ts` + +- [ ] **Step 1: Write the failing test** (mirror `tests/client/api.test.ts`; mock global `fetch`) + +```ts +// tests/client/config-api.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchConfigRawSafe, saveConfigSafe } from '../../src/client/lib/api'; + +beforeEach(() => { vi.restoreAllMocks(); }); + +describe('config api helpers', () => { + it('fetchConfigRawSafe returns content on 200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data.content).toBe('runs: []'); + }); + + it('saveConfigSafe surfaces a 409 conflict', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'wiki changed', conflict: true }), + { status: 409, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-api.test.ts` Expected: FAIL. + +- [ ] **Step 3: Add wire types** to `src/client/lib/types.ts` + +```ts +export type ConfigRaw = { content: string; revisionId: string | null; isDefaultTemplate: boolean }; +export type SaveResult = { rev: number; ruleCount: number }; +export type SimResult = { totalSamples: number; firedCount: number; erroredCount: number; firstError?: string }; +``` + +- [ ] **Step 4: Implement helpers** in `src/client/lib/api.ts` (follow the existing `ApiResult` + `extractServerError` + `demoSuffix` pattern already in the file) + +```ts +import type { ConfigRaw, SaveResult, SimResult } from './types'; + +export async function fetchConfigRawSafe(): Promise> { + try { + const res = await fetch(`/api/config/raw${demoSuffix()}`); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as ConfigRaw; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function validateConfigSafe(text: string): Promise<{ ok: boolean; errors?: unknown }> { + try { + const res = await fetch('/api/config/validate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + return (await res.json()) as { ok: boolean; errors?: unknown }; + } catch (err) { + return { ok: false, errors: err instanceof Error ? err.message : String(err) }; + } +} + +export async function simulateLiveSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/simulate-live', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as { ok: boolean; error?: string } & SimResult; + if (!data.ok) return { ok: false, error: data.error ?? 'simulation failed' }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function explainConfigSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/explain', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + const data = (await res.json()) as { ok: boolean; explanation?: string; error?: string }; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? await extractServerError(res) }; + return { ok: true, empty: false, data: data.explanation ?? '' }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function saveConfigSafe(text: string, baseRevisionId: string | null): Promise> { + try { + const res = await fetch('/api/config/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, baseRevisionId }), + }); + const data = (await res.json()) as { ok: boolean; error?: string } & SaveResult; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? await extractServerError(res) }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} +``` + +- [ ] **Step 5: Run + commit** + +Run: `npm test -- tests/client/config-api.test.ts && npm run type-check` +```bash +git add src/client/lib/api.ts src/client/lib/types.ts tests/client/config-api.test.ts +git commit -m "feat(client): config editor API helpers (raw/validate/simulate/explain/save)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 10: `ConfigEditor` (CodeMirror 6 wrapper) + +**Files:** +- Create: `src/client/components/ConfigEditor.tsx` +- Test: `tests/client/config-editor.test.tsx` + +- [ ] **Step 1: Write the failing test** (jsdom render; assert the editor host mounts + shows the doc) + +```tsx +// tests/client/config-editor.test.tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { ConfigEditor } from '../../src/client/components/ConfigEditor'; + +describe('ConfigEditor', () => { + it('mounts and renders the initial document', () => { + const { container } = render( + {}} /> + ); + expect(container.querySelector('.cm-editor')).toBeTruthy(); + expect(container.textContent).toContain('runs'); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-editor.test.tsx` Expected: FAIL (module not found). + +- [ ] **Step 3: Implement** + +```tsx +// src/client/components/ConfigEditor.tsx +import { useEffect, useRef } from 'react'; +import { EditorState } from '@codemirror/state'; +import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { yaml } from '@codemirror/lang-yaml'; +import { json } from '@codemirror/lang-json'; +import { lintGutter } from '@codemirror/lint'; +import { yamlSchema, jsonSchema } from 'codemirror-json-schema'; +import appSchema from '../../schema/app.schema.json'; + +// Devvit may enforce a strict style-src; pass the nonce if the platform injects one. +const nonce = (document.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null)?.content; + +export function ConfigEditor({ + value, format, onChange, +}: { value: string; format: 'yaml' | 'json'; onChange: (text: string) => void }) { + const host = useRef(null); + const view = useRef(null); + + useEffect(() => { + if (!host.current) return; + const schemaExt = format === 'json' + ? jsonSchema(appSchema as object) + : yamlSchema(appSchema as object); + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), highlightActiveLine(), history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + format === 'json' ? json() : yaml(), + schemaExt, + lintGutter(), + EditorView.updateListener.of((u) => { if (u.docChanged) onChange(u.state.doc.toString()); }), + ...(nonce ? [EditorView.cspNonce.of(nonce)] : []), + EditorView.theme({ '&': { height: '100%', fontSize: '13px' }, '.cm-scroller': { fontFamily: 'var(--cm-mono, monospace)' } }), + ], + }); + view.current = new EditorView({ state, parent: host.current }); + return () => { view.current?.destroy(); view.current = null; }; + // Re-init on format switch only; live value changes flow through onChange. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [format]); + + return
; +} +``` + +NOTE: confirm `codemirror-json-schema` exports `yamlSchema` + `jsonSchema` at the installed version (per its README); if the names differ, adjust the import. They each return a CM6 extension wiring schema completion + hover + lint. + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/client/config-editor.test.tsx && npm run type-check` +```bash +git add src/client/components/ConfigEditor.tsx tests/client/config-editor.test.tsx +git commit -m "feat(client): CodeMirror 6 ConfigEditor with schema hints + lint" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 11: `ConfigWorkbench` + ActionBar button + App wiring + +**Files:** +- Create: `src/client/components/ConfigWorkbench.tsx` +- Modify: `src/client/components/ActionBar.tsx`, `src/client/App.tsx` +- Test: `tests/client/config-workbench.test.tsx` + +- [ ] **Step 1: Write the failing test** (mock the api helpers; render workbench; assert load + Save call) + +```tsx +// tests/client/config-workbench.test.tsx +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; + +const fetchConfigRawSafe = vi.fn(); +const saveConfigSafe = vi.fn(); +const validateConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + fetchConfigRawSafe: () => fetchConfigRawSafe(), + saveConfigSafe: (...a: unknown[]) => saveConfigSafe(...a), + validateConfigSafe: (...a: unknown[]) => validateConfigSafe(...a), + simulateLiveSafe: vi.fn(async () => ({ ok: true, empty: false, data: { totalSamples: 0, firedCount: 0, erroredCount: 0 } })), + explainConfigSafe: vi.fn(async () => ({ ok: true, empty: false, data: '' })), +})); +vi.mock('@devvit/web/client', () => ({ requestExpandedMode: vi.fn() })); + +import { ConfigWorkbench } from '../../src/client/components/ConfigWorkbench'; + +beforeEach(() => { + fetchConfigRawSafe.mockResolvedValue({ ok: true, empty: false, data: { content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false } }); + validateConfigSafe.mockResolvedValue({ ok: true }); + saveConfigSafe.mockResolvedValue({ ok: true, empty: false, data: { rev: 1, ruleCount: 0 } }); +}); + +describe('ConfigWorkbench', () => { + it('loads config then saves', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(saveConfigSafe).toHaveBeenCalled()); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/client/config-workbench.test.tsx` Expected: FAIL. + +- [ ] **Step 3: Implement `ConfigWorkbench`** + +```tsx +// src/client/components/ConfigWorkbench.tsx +import { useEffect, useRef, useState } from 'react'; +import { ConfigEditor } from './ConfigEditor'; +import { PreviewPane } from './PreviewPane'; +import { fetchConfigRawSafe, validateConfigSafe, saveConfigSafe } from '../lib/api'; + +function detectFormat(text: string): 'yaml' | 'json' { + const t = text.trimStart(); + return t.startsWith('{') || t.startsWith('[') ? 'json' : 'yaml'; +} + +export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onClose: () => void }) { + const [text, setText] = useState(''); + const [format, setFormat] = useState<'yaml' | 'json'>('yaml'); + const [baseRev, setBaseRev] = useState(null); + const [valid, setValid] = useState(null); + const [status, setStatus] = useState('Loading...'); + const [saving, setSaving] = useState(false); + const debounce = useRef | null>(null); + + useEffect(() => { + (async () => { + const r = await fetchConfigRawSafe(); + if (r.ok && !r.empty) { + setText(r.data.content); setFormat(detectFormat(r.data.content)); + setBaseRev(r.data.revisionId); + setStatus(r.data.isDefaultTemplate ? 'New config (template)' : 'Loaded'); + } else { + setStatus(r.ok ? 'Empty' : `Load failed: ${r.error}`); + } + })(); + }, []); + + function onChange(next: string) { + setText(next); + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const v = await validateConfigSafe(next); + setValid(v.ok); + }, 600); + } + + async function onSave() { + if (saving || valid === false) return; + setSaving(true); setStatus('Saving...'); + const r = await saveConfigSafe(text, baseRev); + if (r.ok && !r.empty) { setStatus(`Saved, ${r.data.ruleCount} rules live (rev ${r.data.rev})`); } + else { setStatus(r.ok ? 'Saved' : `Save failed: ${r.error}`); } + setSaving(false); + } + + return ( +
+
+ Edit config: r/{subreddit} +
+ {valid === false ? 'invalid' : valid === true ? 'valid' : ''} + + +
+
+
+
+
+
+
{status}
+
+ ); +} +``` + +- [ ] **Step 4: ActionBar button.** In `src/client/components/ActionBar.tsx`, add `onEditConfig: () => void` to the props type and render a button in the left group (next to Reload), using the lucide `FileEdit` icon: + +```tsx + +``` + +Add `FileEdit` to the `lucide-react` import line. + +- [ ] **Step 5: App wiring.** In `src/client/App.tsx`: + - Add `const [editorOpen, setEditorOpen] = useState(false);` with the other state. + - Add a lazy import at top: `const ConfigWorkbench = lazy(() => import('./components/ConfigWorkbench').then(m => ({ default: m.ConfigWorkbench })));` and `import { lazy, Suspense } from 'react';` + - Pass `onEditConfig={() => setEditorOpen(true)}` to ``. + - In the Escape keyboard shortcut handler, add `setEditorOpen(false);`. + - Render near the other overlays: + ```tsx + {editorOpen && ( + + setEditorOpen(false)} /> + + )} + ``` + +- [ ] **Step 6: Run tests + type-check + lint + build** + +Run: `npm test -- tests/client/config-workbench.test.tsx && npm run type-check && npm run lint && npm run build` +Expected: PASS; build emits a separate lazy chunk for the workbench. + +- [ ] **Step 7: Commit** + +```bash +git add src/client/components/ConfigWorkbench.tsx src/client/components/ActionBar.tsx src/client/App.tsx tests/client/config-workbench.test.tsx +git commit -m "feat(client): ConfigWorkbench + Edit config entry (lazy-loaded)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +NOTE: `requestExpandedMode` from `@devvit/web/client` must be called from the click handler (a trusted event) to satisfy Devvit. Wire it inside `ActionBar`'s `onEditConfig` click (call `requestExpandedMode(e, 'default')` then `onEditConfig()`); pass the event through. Confirm the exact arg shape against the Devvit docs during Task 18 verification. + +--- + +## PHASE 3 — Schema hints + +### Task 12: Add `description` fields to the schema + +**Files:** +- Create: `scripts/gen-schema-descriptions.mjs` +- Modify: `src/schema/app.schema.json` +- Test: `tests/schema/descriptions.test.ts` + +`app.schema.json` has no `description` fields, so hover docs are empty. The JSDoc on the interfaces in `src/shared/types.ts` is the source of truth. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/schema/descriptions.test.ts +import { describe, it, expect } from 'vitest'; +import schema from '../../src/schema/app.schema.json'; + +describe('schema descriptions', () => { + it('documents the top-level runs property', () => { + const runs = (schema as any).properties?.runs; + expect(typeof runs?.description).toBe('string'); + expect(runs.description.length).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** Run: `npm test -- tests/schema/descriptions.test.ts` Expected: FAIL. + +- [ ] **Step 3: Implement** — hand-add `description` to the common properties for v1 (root `runs`, and within each rule/action definition the high-traffic fields: `kind`, `name`, `checks`, `rules`, `actions`, `authorIs`, `itemIs`). Keep the text short and copied from the matching JSDoc in `src/shared/types.ts`. Example edit to `app.schema.json`: + +```json +"runs": { + "type": "array", + "description": "Ordered list of runs. Each run groups checks evaluated against new posts and comments.", + "items": { "$ref": "#/definitions/Run" } +} +``` + +Also create `scripts/gen-schema-descriptions.mjs` that reads `src/shared/types.ts`, extracts each interface property's leading JSDoc, and writes matching `description` fields, so future schema changes can regenerate rather than hand-edit. Add an npm script `"gen:schema-docs": "node scripts/gen-schema-descriptions.mjs"`. (For v1 the hand-added descriptions are the shipping artifact; the generator is the maintenance path.) + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/schema/descriptions.test.ts && npm run type-check` +```bash +git add src/schema/app.schema.json scripts/gen-schema-descriptions.mjs package.json tests/schema/descriptions.test.ts +git commit -m "feat(schema): add property descriptions for editor hover docs" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 4 — Preview pane + +### Task 13: `PreviewPane` shell + Explain tab + +**Files:** +- Create: `src/client/components/PreviewPane.tsx` +- Test: `tests/client/preview-pane.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```tsx +// tests/client/preview-pane.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +vi.mock('../../src/client/lib/api', () => ({ + simulateLiveSafe: vi.fn(async () => ({ ok: true, empty: false, data: { totalSamples: 25, firedCount: 3, erroredCount: 0 } })), + explainConfigSafe: vi.fn(async () => ({ ok: true, empty: false, data: 'Explanation text' })), +})); +import { PreviewPane } from '../../src/client/components/PreviewPane'; + +describe('PreviewPane', () => { + it('shows the three tabs', () => { + render(); + expect(screen.getByRole('tab', { name: /impact/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /explain/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /diff/i })).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** (tabs; Impact debounced; Explain on demand; Diff placeholder wired in Task 14) + +```tsx +// src/client/components/PreviewPane.tsx +import { useEffect, useRef, useState } from 'react'; +import { simulateLiveSafe, explainConfigSafe } from '../lib/api'; + +type Tab = 'impact' | 'explain' | 'diff'; + +export function PreviewPane({ text }: { text: string }) { + const [tab, setTab] = useState('impact'); + const [impact, setImpact] = useState('edit to preview'); + const [explanation, setExplanation] = useState(''); + const debounce = useRef | null>(null); + + useEffect(() => { + if (tab !== 'impact') return; + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const r = await simulateLiveSafe(text); + setImpact(r.ok && !r.empty ? `Would fire on ${r.data.firedCount}/${r.data.totalSamples} recent items` : r.ok ? 'no result' : r.error); + }, 700); + return () => { if (debounce.current) clearTimeout(debounce.current); }; + }, [text, tab]); + + async function runExplain() { + setExplanation('...'); + const r = await explainConfigSafe(text); + setExplanation(r.ok && !r.empty ? r.data : `Explain failed: ${r.ok ? 'empty' : r.error}`); + } + + return ( +
+
+ + + +
+
+ {tab === 'impact' &&

{impact}

} + {tab === 'explain' && (
{explanation}
)} + {tab === 'diff' &&

Diff vs current rev (Task 14).

} +
+
+ ); +} +``` + +- [ ] **Step 4: Run + commit** + +Run: `npm test -- tests/client/preview-pane.test.tsx && npm run type-check` +```bash +git add src/client/components/PreviewPane.tsx tests/client/preview-pane.test.tsx +git commit -m "feat(client): PreviewPane with live Impact + AI Explain tabs" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 14: Diff tab (edited text vs current rev) + +**Files:** +- Modify: `src/client/components/PreviewPane.tsx` +- Possibly modify: `src/client/components/ConfigDiffViewer.tsx` (export its LCS line-diff helper if not already exported) +- Test: `tests/client/preview-pane.test.tsx` (extend) + +- [ ] **Step 1: Write the failing test** — assert the Diff tab renders changed lines when `text` differs from a `currentText` prop. + +```tsx +it('diff tab shows changed lines', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + expect(screen.getByText(/runs: \[a\]/)).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** — add an optional `currentText?: string` prop to `PreviewPane`; in the diff tab, reuse the LCS line-diff helper from `ConfigDiffViewer.tsx` (export it from there if needed) to render added/removed lines between `currentText` and `text`. `ConfigWorkbench` passes `currentText` = the originally-loaded content. + +- [ ] **Step 4: Wire `currentText` from `ConfigWorkbench`** — store the loaded content in a ref (`loadedText`) and pass it: ``. + +- [ ] **Step 5: Run + commit** + +Run: `npm test -- tests/client/preview-pane.test.tsx && npm run type-check` +```bash +git add src/client/components/PreviewPane.tsx src/client/components/ConfigDiffViewer.tsx src/client/components/ConfigWorkbench.tsx tests/client/preview-pane.test.tsx +git commit -m "feat(client): Diff tab shows edited config vs current rev" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE 5 — Polish + verification + +### Task 15: Conflict UX + format toggle + template seeding + +**Files:** Modify `src/client/components/ConfigWorkbench.tsx`. Test: extend `tests/client/config-workbench.test.tsx`. + +- [ ] **Step 1: Write failing tests** — (a) on a 409 save result, status shows a reload prompt and a "Reload" button appears; (b) a format toggle button switches `format` between yaml/json. + +```tsx +it('shows a reload prompt on 409 conflict', async () => { + saveConfigSafe.mockResolvedValue({ ok: false, error: 'The wiki changed since you opened the editor. Reload to merge.' }); + render( {}} />); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(screen.getByText(/wiki changed/i)).toBeTruthy()); + expect(screen.getByRole('button', { name: /reload/i })).toBeTruthy(); +}); +``` + +- [ ] **Step 2: Run, expect fail.** + +- [ ] **Step 3: Implement** — add a `conflict` state set when `saveConfigSafe` returns an error containing "wiki changed"; render a Reload button that re-runs the loader; add a format toggle button in the header that flips `format` (the editor re-inits on format change per Task 10). Seed the template note when `isDefaultTemplate` is true. + +- [ ] **Step 4: Run + commit** + +```bash +git add src/client/components/ConfigWorkbench.tsx tests/client/config-workbench.test.tsx +git commit -m "feat(client): conflict-reload UX + format toggle + template seeding" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 16: E2E (Playwright, demo mode) + +**Files:** Create `tests/e2e/config-editor.spec.ts` (mirror the existing E2E setup; serve via the dev mock server / built dashboard). + +- [ ] **Step 1: Write the E2E test** + +```ts +// tests/e2e/config-editor.spec.ts +import { test, expect } from '@playwright/test'; + +test('open editor, edit, see impact, save', async ({ page }) => { + await page.goto('http://127.0.0.1:5173/?demo=1'); + await page.getByRole('button', { name: /edit config/i }).click(); + await expect(page.locator('.cm-editor')).toBeVisible(); + await page.locator('.cm-content').click(); + await page.keyboard.type('\nruns: []'); + await page.getByRole('tab', { name: /impact/i }).click(); + await expect(page.getByText(/would fire on/i)).toBeVisible(); + await page.getByRole('button', { name: /save/i }).click(); + await expect(page.getByText(/saved|rules live/i)).toBeVisible(); +}); +``` + +NOTE: demo mode must back the new endpoints. Add `?demo=1` handling to `/api/config/raw` (return a sample YAML config), `/simulate-live` (return a canned `{ ok:true, totalSamples:25, firedCount:3 }`), and `/save` (return `{ ok:true, rev:1, ruleCount:1 }` without writing) so the dashboard runs end-to-end with no live install. Add these demo branches to `configEditor.ts` and a quick unit assertion for each. + +- [ ] **Step 2: Run** — Run: `npm run build && npx playwright test tests/e2e/config-editor.spec.ts` (start the mock server per the existing E2E harness). Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/e2e/config-editor.spec.ts src/routes/configEditor.ts +git commit -m "test(e2e): config editor open/edit/impact/save happy path (demo)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task 17: Devvit build-phase verification (playtest) + +**Files:** none (operational). Record results in the PR description. + +- [ ] **Step 1:** `npx devvit playtest `; open the dashboard custom post; click Edit config. Confirm: (a) the CM6 editor renders (no blank pane), (b) the browser console shows no CSP violation; if a `style-src` violation appears, confirm the `meta[property="csp-nonce"]` exists and is read by `ConfigEditor` (if Devvit injects the nonce under a different selector, update the selector). +- [ ] **Step 2:** Type a known-bad config; confirm inline lint squiggles + Save disabled. Type a valid config; confirm Impact shows a fire-rate. +- [ ] **Step 3:** Click Save; confirm `reddit.updateWikiPage` succeeds (open the wiki page and verify the new content + the "Edited via ContextMod Observatory" revision reason). If it 403s, the app account lacks wiki-edit on the sub: confirm the app's moderator permissions include wiki, or document the required permission in the README. +- [ ] **Step 4:** Re-check the dashboard initial JS chunk size (`npm run build` output) is unchanged from before this feature; confirm the workbench is a separate lazy chunk. +- [ ] **Step 5: Commit** any selector/permission fixes found, then open the PR. + +```bash +git commit -am "fix(client): align CSP nonce selector with Devvit webview" -m "Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Self-review (run before handoff) + +- Spec coverage: load (T3), validate (T4), live impact (T2+T5+T13/14), AI explain (T1+T6+T13), diff (T14), save-back with validation gate + optimistic lock (T7), editor + schema hints (T8-T12), surface + lazy-load (T11), conflict/format/template polish (T15), tests + e2e (per task + T16), build-phase verification (T17). All spec sections map to a task. +- Placeholder scan: every code step has real code; the two `NOTE` items (confirm `publish` return; confirm `codemirror-json-schema` export names) are explicit verification steps with a fallback, not blanks. +- Type consistency: `ConfigRaw`/`SaveResult`/`SimResult` defined in T9 are used consistently in T9-T15; `ApiResult` matches the existing client convention; endpoint paths (`/api/config/raw|validate|simulate-live|explain|save`) are consistent across server (T3-T7) and client (T9). + +--- + +## Open verification items carried into build + +1. `publish(config, sub)` return value (rev number vs void) — T7 NOTE. +2. `codemirror-json-schema` export names at the installed version — T10 NOTE. +3. Devvit `requestExpandedMode` exact signature — T11 NOTE. +4. Devvit webview CSP `style-src` nonce selector — T17. +5. App account wiki-edit permission — T17. From b0d166d1c954e996b921265835d8ba3961976cb8 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:11:01 -0400 Subject: [PATCH 03/21] refactor: extract resolveOpenaiKey into a shared lib Moves the private resolveOpenaiKey helper from forms.ts into src/lib/resolveOpenaiKey.ts so the upcoming config-editor explain endpoint can share the same Redis-first, settings-fallback key resolution logic without duplication. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/resolveOpenaiKey.ts | 15 +++++++++++++++ src/routes/forms.ts | 19 ++----------------- tests/lib/resolveOpenaiKey.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 17 deletions(-) create mode 100644 src/lib/resolveOpenaiKey.ts create mode 100644 tests/lib/resolveOpenaiKey.test.ts diff --git a/src/lib/resolveOpenaiKey.ts b/src/lib/resolveOpenaiKey.ts new file mode 100644 index 0000000..d4047f0 --- /dev/null +++ b/src/lib/resolveOpenaiKey.ts @@ -0,0 +1,15 @@ +import { settings } from '@devvit/web/server'; +import { getOpenaiKey } from '../state/apiKeyStore'; + +/** Resolve the OpenAI key for a sub: encrypted-Redis key first, then the + * plaintext Devvit subreddit-setting fallback. Returns '' when neither set. + * + * Note: getOpenaiKey swallows Redis read errors and returns null, so a Redis + * outage silently falls through to the settings fallback rather than throwing. + * Callers see '' only when both sources are genuinely absent. */ +export async function resolveOpenaiKey(sub: string): Promise { + const fromRedis = await getOpenaiKey(sub); + // getOpenaiKey returns null when absent; '' can't come from it (trimmed at write time). + if (fromRedis) return fromRedis; + return ((await settings.get('openai_api_key')) ?? '').trim(); +} diff --git a/src/routes/forms.ts b/src/routes/forms.ts index acebcef..9f2b050 100644 --- a/src/routes/forms.ts +++ b/src/routes/forms.ts @@ -26,29 +26,14 @@ import { import * as configStore from '../state/configStore'; import { simulateRule, formatSimulationToast, type SimulationSample } from '../core/simulateRule'; import { explainRule, formatExplainToast } from '../core/explainRule'; -import { settings } from '@devvit/web/server'; -import { setOpenaiKey, getOpenaiKey } from '../state/apiKeyStore'; +import { setOpenaiKey } from '../state/apiKeyStore'; import { requireModerator } from '../lib/requireModerator'; import { checkRateLimit } from '../lib/ratelimit'; import { checkCircuit, recordFailure, recordSuccess } from '../lib/circuitBreaker'; import { type Result, ok, err } from '../lib/result'; import { log } from '../lib/log'; import { isTransientOpenaiError } from '../lib/openaiErrors'; - -/** - * Wave V hotfix — resolve OpenAI API key with fallback chain: - * 1. Redis (preferred — set via "ContextMod: Set OpenAI API key" mod menu) - * 2. Devvit subreddit setting (fallback for mods who prefer settings UI) - * - * Devvit CLI for global-scope settings is broken (Unimplemented RPC). - * Subreddit-scope settings don't allow isSecret. Redis is the cleanest path. - */ -async function resolveOpenaiKey(sub: string): Promise { - const fromRedis = await getOpenaiKey(sub); - if (fromRedis) return fromRedis; - const fromSettings = ((await settings.get('openai_api_key')) ?? '').trim(); - return fromSettings; -} +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; import type { AppConfig } from '../shared/types'; export const forms = new Hono(); diff --git a/tests/lib/resolveOpenaiKey.test.ts b/tests/lib/resolveOpenaiKey.test.ts new file mode 100644 index 0000000..d109924 --- /dev/null +++ b/tests/lib/resolveOpenaiKey.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getOpenaiKey = vi.fn(); +const settingsGet = vi.fn(); +vi.mock('../../src/state/apiKeyStore', () => ({ getOpenaiKey: (s: string) => getOpenaiKey(s) })); +vi.mock('@devvit/web/server', () => ({ settings: { get: (k: string) => settingsGet(k) } })); + +import { resolveOpenaiKey } from '../../src/lib/resolveOpenaiKey'; + +describe('resolveOpenaiKey', () => { + beforeEach(() => { getOpenaiKey.mockReset(); settingsGet.mockReset(); }); + + it('prefers the Redis key', async () => { + getOpenaiKey.mockResolvedValue('sk-redis'); + expect(await resolveOpenaiKey('sub')).toBe('sk-redis'); + expect(settingsGet).not.toHaveBeenCalled(); + }); + + it('falls back to settings, trimmed', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(' sk-settings '); + expect(await resolveOpenaiKey('sub')).toBe('sk-settings'); + }); + + it('returns empty string when neither source has a key', async () => { + getOpenaiKey.mockResolvedValue(null); + settingsGet.mockResolvedValue(undefined); + expect(await resolveOpenaiKey('sub')).toBe(''); + }); +}); From fc8ac43831a7085573b03c34a81580ee047db9b6 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:22:13 -0400 Subject: [PATCH 04/21] refactor: extract cached getRecentSample for reuse by the editor Moves the fetchRecentPostsSafe + normalize loop out of the /simulate-rule-submit handler into src/core/recentSample.ts. Caches the result in Redis for 60 s so the future live-impact editor endpoint can reuse it without re-fetching Reddit on every debounced keystroke. Per-post normalize errors are logged and skipped (non-fatal). Cache read/write failures are also non-fatal so a Redis blip never breaks the simulation path. 4 unit tests cover the cache-hit, cache-miss, empty-listing, and cache-write-fail branches. All 96 route tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/core/recentSample.ts | 138 ++++++++++++++ src/routes/forms.ts | 146 ++------------- src/shared/normalize.ts | 15 ++ tests/core/recentSample.test.ts | 220 +++++++++++++++++++++++ tests/routes/forms-openai-key.test.ts | 7 + tests/routes/forms-simulate-rule.test.ts | 7 + tests/routes/forms-test-rules.test.ts | 7 + 7 files changed, 404 insertions(+), 136 deletions(-) create mode 100644 src/core/recentSample.ts create mode 100644 tests/core/recentSample.test.ts diff --git a/src/core/recentSample.ts b/src/core/recentSample.ts new file mode 100644 index 0000000..4a84753 --- /dev/null +++ b/src/core/recentSample.ts @@ -0,0 +1,138 @@ +/** + * Task 2: getRecentSample — cached recent-post helper. + * + * Fetches the sub's N most recent posts via reddit.getNewPosts, normalizes + * them to SimulationSample[], and caches the result in Redis for 60 s. + * + * Both the simulate-rule form handler (forms.ts) and the future live-impact + * editor endpoint share this helper so neither re-fetches Reddit on every + * keystroke; the 60 s TTL keeps the preview "fresh enough" while staying + * well within Reddit API rate limits. + * + * The implementation mirrors the inline sample-building loop that previously + * lived in forms.ts /simulate-rule-submit. Any change to the normalization + * path must be reflected here to keep simulation and live-moderation + * consistent. + */ + +import { reddit, redis } from '@devvit/web/server'; +import type { SimulationSample } from './simulateRule'; +import { normalizePost, asPayloadTimestamp, type PostSubmitPayload } from '../shared/normalize'; +import { getCurrentRev } from '../state/configStore'; +import type { AppConfig } from '../shared/types'; +import { log } from '../lib/log'; + +const SAMPLE_LIMIT = 25; +// 60 s: fresh enough for live preview, no per-keystroke Reddit fetch. +const CACHE_TTL_MS = 60_000; + +function cacheKey(sub: string): string { + return `cm:${sub}:recent-sample`; +} + +interface RedditPostLike { + id?: string; + title?: string; + body?: string; + url?: string; + authorId?: string; + authorName?: string; + score?: number; + nsfw?: boolean; + locked?: boolean; + stickied?: boolean; + createdAt?: number | Date | string; +} + + +/** + * Recent posts of the sub, normalized to SimulationSample[], cached 60 s. + * + * On cache hit: returns the cached slice immediately (no Reddit call). + * On cache miss: fetches via reddit.getNewPosts, normalizes, writes through to + * Redis, returns the slice. Cache write failures are non-fatal (logged + ignored). + * + * Per-post normalize errors are also non-fatal: the offending post is skipped + * and logged; the remaining samples are returned. This mirrors the same posture + * the forms.ts simulate-rule handler uses for its per-post try/catch. + */ +export async function getRecentSample(sub: string): Promise { + // Cache read. + try { + const cached = await redis.get(cacheKey(sub)); + if (cached != null) return JSON.parse(cached) as SimulationSample[]; + } catch { + /* cache read miss/fail — fall through to fetch */ + } + + // Fetch current config for author-enrichment decision — same path as + // the forms.ts simulate handler so normalization is consistent. + const snapshot = await getCurrentRev(sub); + const config: AppConfig = snapshot?.config ?? { + runs: [], + needsAuthorEnrichment: false, + }; + + // reddit.getNewPosts is not in the typed @devvit/web/server surface; cast + // to avoid `ts(2339)`. This matches the same cast in forms.ts. + const redditAny = reddit as unknown as { + getNewPosts: (opts: { + subredditName: string; + limit: number; + pageSize: number; + }) => Promise<{ all: () => Promise }> | { all: () => Promise }; + }; + + const listing = await redditAny.getNewPosts({ + subredditName: sub, + limit: SAMPLE_LIMIT, + pageSize: SAMPLE_LIMIT, + }); + // I1: guard against a null/undefined listing before accessing .all. + if (!listing || typeof listing.all !== 'function') return []; + const allPosts = await listing.all(); + const recent = allPosts.slice(0, SAMPLE_LIMIT); + + const samples: SimulationSample[] = []; + for (const post of recent) { + try { + const payload: PostSubmitPayload = { + post: { + id: post.id, + title: post.title, + selftext: post.body ?? '', + url: post.url ?? '', + authorId: post.authorId ?? '', + score: post.score ?? 0, + // I2: prefer the real isSelf field from the Reddit API; fall back to + // URL heuristic only when the field is absent. The URL heuristic + // diverges from the dry-run/test-rules path which reads isSelf directly. + isSelf: typeof (post as { isSelf?: boolean }).isSelf === 'boolean' + ? !!(post as { isSelf?: boolean }).isSelf + : !!(post as { url?: string }).url?.includes(sub), + nsfw: !!post.nsfw, + locked: !!post.locked, + stickied: !!post.stickied, + createdAt: asPayloadTimestamp(post.createdAt), + }, + author: { name: post.authorName ?? '', id: post.authorId ?? '' }, + } as PostSubmitPayload; + const normalized = await normalizePost(payload, config); + samples.push({ item: normalized.item, author: normalized.author }); + } catch (err) { + log.warn('cm/core/getRecentSample', 'skipped sample', { err }); + } + } + + // Cache write-through. Non-fatal: a Redis blip must not kill the sample + // fetch path, only the caching benefit. + try { + await redis.set(cacheKey(sub), JSON.stringify(samples), { + expiration: new Date(Date.now() + CACHE_TTL_MS), + }); + } catch { + /* cache write fail is non-fatal */ + } + + return samples; +} diff --git a/src/routes/forms.ts b/src/routes/forms.ts index 9f2b050..e9753c4 100644 --- a/src/routes/forms.ts +++ b/src/routes/forms.ts @@ -20,17 +20,18 @@ import { dryRunActivity } from '../core/dryRunActivity'; import { normalizePost, normalizeComment, + asPayloadTimestamp, type PostSubmitPayload, type CommentSubmitPayload, } from '../shared/normalize'; import * as configStore from '../state/configStore'; -import { simulateRule, formatSimulationToast, type SimulationSample } from '../core/simulateRule'; +import { simulateRule, formatSimulationToast } from '../core/simulateRule'; +import { getRecentSample } from '../core/recentSample'; import { explainRule, formatExplainToast } from '../core/explainRule'; import { setOpenaiKey } from '../state/apiKeyStore'; import { requireModerator } from '../lib/requireModerator'; import { checkRateLimit } from '../lib/ratelimit'; import { checkCircuit, recordFailure, recordSuccess } from '../lib/circuitBreaker'; -import { type Result, ok, err } from '../lib/result'; import { log } from '../lib/log'; import { isTransientOpenaiError } from '../lib/openaiErrors'; import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; @@ -38,8 +39,6 @@ import type { AppConfig } from '../shared/types'; export const forms = new Hono(); -const SIMULATION_SAMPLE_LIMIT = 25; - /** * Map requireModerator() failure to user-facing toast text. * @@ -86,13 +85,6 @@ interface FetchedComment { createdAt?: number | Date | string; } -function asPayloadTimestamp(t?: number | Date | string): number | string | undefined { - if (t == null) return undefined; - if (typeof t === 'number') return t; - if (typeof t === 'string') return t; - return t.getTime(); -} - forms.post('/test-rules-submit', async (c) => { // Live-playtest 2026-05-16 revealed Devvit form submit envelope is FLAT: // `{thingId: '...'}`, NOT `{values: {thingId: '...'}}` per the doc convention @@ -249,79 +241,14 @@ forms.post('/simulate-rule-submit', async (c) => { try { const sub = await reddit.getCurrentSubreddit(); - // Reuse the live AppConfig snapshot for needsAuthorEnrichment decisions - // — same enrichment path live rules use, so simulation matches reality. - const snapshot = await configStore.getCurrentRev(sub.name); - const config: AppConfig = snapshot?.config ?? { - runs: [], - needsAuthorEnrichment: false, - }; - - // reddit.getNewPosts returns a Listing; .all() flattens to an array. - // AD Tier-1 #1: fetchRecentPostsSafe now returns Result — propagate - // reddit-api failure to toast w/ failure phase instead of "fired 0/0". - const recentResult = await fetchRecentPostsSafe(sub.name); - if (!recentResult.ok) { - return c.json({ - showToast: `Simulation failed (reddit-api): ${recentResult.error}`, - }); - } - const recent = recentResult.value; - const samples: SimulationSample[] = []; - let skipped = 0; - let firstSkipError: string | null = null; - for (const post of recent) { - try { - const payload: PostSubmitPayload = { - post: { - id: post.id, - title: post.title, - selftext: post.body ?? '', - url: post.url ?? '', - authorId: post.authorId ?? '', - score: post.score ?? 0, - isSelf: !!post.url?.includes(sub.name), - nsfw: !!post.nsfw, - locked: !!post.locked, - stickied: !!post.stickied, - createdAt: asPayloadTimestamp(post.createdAt), - }, - author: { name: post.authorName ?? '', id: post.authorId ?? '' }, - } as PostSubmitPayload; - const normalized = await normalizePost(payload, config); - samples.push({ item: normalized.item, author: normalized.author }); - } catch (perPostErr) { - // AD Tier-1 #2 + HIGH #4: count skipped samples + capture the FIRST - // failure message so the toast shows a real cause instead of a - // bare "normalize error" the mod can't act on. - skipped += 1; - if (firstSkipError === null) { - firstSkipError = - perPostErr instanceof Error ? perPostErr.message : String(perPostErr); - } - log.warn('cm/forms/simulate-rule-submit', 'skipped sample', { err: perPostErr }); - } - } - - // AD HIGH #3: when every sample failed, simulateRule returns - // totalSamples=0 and formatSimulationToast says "No recent posts to - // simulate against." That's misleading — there WERE recent posts, - // they all failed normalize. Surface the dedicated "aborted" toast - // with the first error instead of a contradictory base + suffix. - if (recent.length > 0 && skipped === recent.length) { - const detail = firstSkipError ? firstSkipError.slice(0, 100) : 'unknown'; - return c.json({ - showToast: `Simulation aborted: every sample failed to normalize (first: ${detail})`, - }); - } - + // getRecentSample fetches + caches the last N posts (60 s TTL) so the + // future live-impact editor endpoint can reuse the same sample without + // re-hitting Reddit on every keystroke. Per-post normalize errors are + // logged + skipped inside getRecentSample; reddit-api errors throw so + // the outer catch below classifies them as the reddit-api phase. + const samples = await getRecentSample(sub.name); const result = await simulateRule(ruleJson5, samples, sub.name); - const baseToast = formatSimulationToast(result); - const suffix = - skipped > 0 - ? ` (${skipped}/${recent.length} skipped — first: ${(firstSkipError ?? '').slice(0, 60)})` - : ''; - return c.json({ showToast: `${baseToast}${suffix}` }); + return c.json({ showToast: formatSimulationToast(result) }); } catch (e) { // Wave U WARN fix (Codex CR3 #7): prefix toast w/ failure phase so mod // knows whether to retry (network/reddit), fix their rule (parse), or @@ -338,20 +265,6 @@ forms.post('/simulate-rule-submit', async (c) => { } }); -interface RedditPostLike { - id?: string; - title?: string; - body?: string; - url?: string; - authorId?: string; - authorName?: string; - score?: number; - nsfw?: boolean; - locked?: boolean; - stickied?: boolean; - createdAt?: number | Date | string; -} - /** * Wave S Phase S5 — AI rule explainer. * Reads openai_api_key from app settings + calls OpenAI chat completions. @@ -473,42 +386,3 @@ forms.post('/set-openai-key-submit', async (c) => { } }); -interface RedditListingLike { - all?: () => Promise | T[]; -} - -/** - * AD Tier-1 bug #1 fix — previously returned `[]` on any failure path, which - * caused the simulator to report "fired 0/0" indistinguishably from a real - * "no rule triggers fired" result. Now returns a discriminated Result so the - * caller can surface the actual failure phase in the toast (judges + mods - * deserve "Reddit API unavailable — try again" not a silent zero). - */ -async function fetchRecentPostsSafe( - subredditName: string -): Promise> { - try { - const redditAny = reddit as unknown as { - getNewPosts?: (opts: { - subredditName: string; - limit: number; - pageSize: number; - }) => Promise>; - }; - if (typeof redditAny.getNewPosts !== 'function') { - return err('reddit.getNewPosts unavailable in this Devvit runtime'); - } - const listing = await redditAny.getNewPosts({ - subredditName, - limit: SIMULATION_SAMPLE_LIMIT, - pageSize: SIMULATION_SAMPLE_LIMIT, - }); - if (!listing) return err('reddit.getNewPosts returned empty listing'); - const all = typeof listing.all === 'function' ? await listing.all() : []; - return ok(all.slice(0, SIMULATION_SAMPLE_LIMIT)); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - log.warn('cm/forms/simulate-rule-submit', 'fetchRecentPostsSafe failed', { err: e }); - return err(`reddit-api: ${msg}`); - } -} diff --git a/src/shared/normalize.ts b/src/shared/normalize.ts index 95f6a89..f37b241 100644 --- a/src/shared/normalize.ts +++ b/src/shared/normalize.ts @@ -240,6 +240,21 @@ async function enrichAuthor(name: string, id: string, needsEnrichment: boolean): } // Public API. + +/** + * M1: single source of truth for converting a createdAt value (number | Date | string) + * to the number | string shape that PostSubmitPayload and CommentSubmitPayload accept. + * Previously duplicated in recentSample.ts and forms.ts. + */ +export function asPayloadTimestamp( + t?: number | Date | string +): number | string | undefined { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return t.getTime(); +} + export interface NormalizedActivity { item: Item; author: Author; diff --git a/tests/core/recentSample.test.ts b/tests/core/recentSample.test.ts new file mode 100644 index 0000000..76350f4 --- /dev/null +++ b/tests/core/recentSample.test.ts @@ -0,0 +1,220 @@ +/** + * Task 2: getRecentSample — cached recent-post helper for live-impact preview. + * + * Mocks @devvit/web/server so the test runs in pure node without any Devvit + * runtime. We test the two caching branches: cache hit (no Reddit call) and + * cache miss (fetch + write through). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getNewPosts = vi.fn(); +const redisGet = vi.fn(); +const redisSet = vi.fn(); + +vi.mock('@devvit/web/server', () => ({ + reddit: { getNewPosts: (o: unknown) => getNewPosts(o) }, + redis: { + get: (k: string) => redisGet(k), + set: (k: string, v: string, o?: unknown) => redisSet(k, v, o), + }, +})); + +// normalizePost is used inside getRecentSample — stub it so we don't need the +// full Reddit enrichment chain. The stub passes id + isSelf through from the +// payload so I2-related assertions can inspect the normalized item. +vi.mock('../../src/shared/normalize', () => ({ + normalizePost: vi.fn( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => ({ + item: { id: p.post.id, isSelf: p.post.isSelf ?? false }, + author: { name: p.author.name }, + }) + ), + // M1: asPayloadTimestamp is now exported from normalize; mock re-exports it + // as a pass-through so recentSample.ts can import it without error. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, +})); + +// configStore: getCurrentRev returns a snapshot with a minimal config. +vi.mock('../../src/state/configStore', () => ({ + getCurrentRev: vi.fn(async () => ({ + rev: 0, + config: { runs: [], needsAuthorEnrichment: false }, + })), +})); + +import { getRecentSample } from '../../src/core/recentSample'; +import { normalizePost } from '../../src/shared/normalize'; + +describe('getRecentSample', () => { + beforeEach(() => { + getNewPosts.mockReset(); + redisGet.mockReset(); + redisSet.mockReset(); + // Reset normalizePost to the default pass-through between tests so M3's + // per-throw mock doesn't bleed into other test cases. + vi.mocked(normalizePost).mockImplementation( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => ({ + item: { id: p.post.id, isSelf: p.post.isSelf ?? false }, + author: { name: p.author.name }, + }) + ); + }); + + it('returns the cached sample without hitting reddit', async () => { + redisGet.mockResolvedValue( + JSON.stringify([{ item: { id: 't3_x' }, author: { name: 'a' } }]) + ); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect(getNewPosts).not.toHaveBeenCalled(); + }); + + it('fetches + caches on a cache miss', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_abc', + title: 'hello', + body: null, + url: 'https://reddit.com/r/sub/comments/abc', + authorName: 'alice', + authorId: 't2_alice', + score: 5, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(getNewPosts).toHaveBeenCalledOnce(); + expect(redisSet).toHaveBeenCalledOnce(); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ item: { id: 't3_abc' }, author: { name: 'alice' } }); + }); + + it('returns empty array when reddit returns no posts', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ all: async () => [] }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(0); + expect(redisSet).toHaveBeenCalledOnce(); + }); + + it('still returns data when cache write fails', async () => { + redisGet.mockResolvedValue(null); + redisSet.mockRejectedValue(new Error('redis down')); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_def', + title: 'test', + body: null, + url: 'https://reddit.com/r/sub/comments/def', + authorName: 'bob', + authorId: 't2_bob', + score: 1, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + }); + + // I2: the real isSelf field must take precedence over the URL heuristic. + // Post has isSelf:true but a URL that does NOT contain the sub name, so the + // URL heuristic would wrongly yield false. The normalized item must carry + // isSelf:true because the real field was present. + it('I2: uses real isSelf field when present, not URL heuristic', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + { + id: 't3_a', + title: 'image post', + body: null, + // URL contains no sub name — heuristic would return false. + url: 'https://i.redd.it/x.jpg', + isSelf: true, + authorName: 'carol', + authorId: 't2_carol', + score: 3, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + // normalizePost stub passes isSelf from the payload onto item.isSelf, so + // if the real field was used the item should be marked as a self post. + expect((out[0].item as { isSelf?: boolean }).isSelf).toBe(true); + }); + + // M3: per-post normalize-error skip. When the first post causes normalizePost + // to throw and the second is valid, the returned array must contain only the + // valid one (length 1) without the function itself throwing. + it('M3: skips a post that causes normalizePost to throw, returns the rest', async () => { + redisGet.mockResolvedValue(null); + getNewPosts.mockReturnValue({ + all: async () => [ + // First post — will trigger a normalizePost throw. + { + id: 't3_bad', + title: 'malformed', + body: null, + url: '', + authorName: 'x', + authorId: 't2_x', + score: 0, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + // Second post — valid, must survive. + { + id: 't3_good', + title: 'ok', + body: null, + url: 'https://reddit.com/r/sub/comments/good', + authorName: 'y', + authorId: 't2_y', + score: 2, + nsfw: false, + locked: false, + stickied: false, + createdAt: Date.now(), + }, + ], + }); + + // Make normalizePost throw on the first call, succeed on the second. + let callCount = 0; + vi.mocked(normalizePost).mockImplementation( + async (p: { post: { id: string; isSelf?: boolean }; author: { name: string; id: string } }) => { + callCount++; + if (callCount === 1) throw new Error('malformed id'); + return { item: { id: p.post.id, isSelf: false }, author: { name: p.author.name } }; + } + ); + + const out = await getRecentSample('sub'); + expect(out).toHaveLength(1); + expect((out[0].item as { id: string }).id).toBe('t3_good'); + }); +}); diff --git a/tests/routes/forms-openai-key.test.ts b/tests/routes/forms-openai-key.test.ts index 446856a..0ffffb4 100644 --- a/tests/routes/forms-openai-key.test.ts +++ b/tests/routes/forms-openai-key.test.ts @@ -52,6 +52,13 @@ vi.mock('../../src/core/dryRunActivity', () => ({ dryRunActivity: vi.fn() })); vi.mock('../../src/shared/normalize', () => ({ normalizePost: vi.fn(), normalizeComment: vi.fn(), + // M1: asPayloadTimestamp moved to shared/normalize — must be present in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/state/configStore', () => ({ getCurrentRev: vi.fn() })); vi.mock('../../src/core/simulateRule', () => ({ diff --git a/tests/routes/forms-simulate-rule.test.ts b/tests/routes/forms-simulate-rule.test.ts index 636a457..7e02dcc 100644 --- a/tests/routes/forms-simulate-rule.test.ts +++ b/tests/routes/forms-simulate-rule.test.ts @@ -52,6 +52,13 @@ vi.mock('../../src/shared/normalize', () => ({ author: { name: 'u', id: 't2_u' }, })), normalizeComment: vi.fn(), + // M1: asPayloadTimestamp moved to shared/normalize — must be present in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/core/dryRunActivity', () => ({ dryRunActivity: vi.fn() })); vi.mock('../../src/core/explainRule', () => ({ diff --git a/tests/routes/forms-test-rules.test.ts b/tests/routes/forms-test-rules.test.ts index e358f6d..2b0c80a 100644 --- a/tests/routes/forms-test-rules.test.ts +++ b/tests/routes/forms-test-rules.test.ts @@ -37,6 +37,13 @@ vi.mock('../../src/core/dryRunActivity', () => ({ vi.mock('../../src/shared/normalize', () => ({ normalizePost: (...a: unknown[]) => normalizePost(...a), normalizeComment: (...a: unknown[]) => normalizeComment(...a), + // M1: asPayloadTimestamp moved to shared/normalize — must be included in the mock. + asPayloadTimestamp: (t?: number | Date | string): number | string | undefined => { + if (t == null) return undefined; + if (typeof t === 'number') return t; + if (typeof t === 'string') return t; + return (t as Date).getTime(); + }, })); vi.mock('../../src/state/configStore', () => ({ getCurrentRev: (...a: unknown[]) => getCurrentRev(...a), From 3439088bb43e01879807c778d026741acee411b7 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:39:41 -0400 Subject: [PATCH 05/21] feat(api): GET /api/config/raw returns wiki text + revisionId Adds config-editor Hono sub-app mounted at /api/config. The single /raw endpoint is mod-auth gated; it returns the wiki page content + revisionId on success, or the DEFAULT_CONFIG_YAML starter template (isDefaultTemplate:true) when the wiki page does not exist yet. DEFAULT_CONFIG_YAML added to default-config.ts as a hand-written YAML block semantically equivalent to DEFAULT_CONFIG_JSON5 and verifiably parseable via parseConfig. 3 new tests; 99/99 suite green; tsc + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/default-config.ts | 39 ++++++++++++++++++--- src/routes/api.ts | 3 ++ src/routes/configEditor.ts | 42 +++++++++++++++++++++++ tests/routes/config-editor.test.ts | 54 ++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/routes/configEditor.ts create mode 100644 tests/routes/config-editor.test.ts diff --git a/src/config/default-config.ts b/src/config/default-config.ts index 96e1f3f..bae2ad7 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -1,17 +1,17 @@ /** * Default config seeded on fresh install (Step 3.1). * - * Minimal — one regex rule + one remove action — so a new install shows + * Minimal (one regex rule + one remove action) so a new install shows * something useful in the Observatory dashboard immediately. Mods replace * this by editing `r//wiki/botconfig/contextmod` (Step 3.2). * * Stored as a JSON5 string (not a JS literal) so the install path exercises - * the same `parseConfig` → AJV pipeline a wiki edit hits — if the schema + * the same `parseConfig` -> AJV pipeline a wiki edit hits. If the schema * tightens and this default drifts, tests catch it. * * AE Polish #28: seed with `dryRun: true` for safety. Previously dryRun * was false, meaning a fresh install would *immediately* auto-remove any - * post matching `scam|giveaway|free crypto` — including legit giveaway + * post matching `scam|giveaway|free crypto`, including legit giveaway * threads on subs like r/HailCorporate. That's a wildly bad first * impression on a sub the mod just installed the bot on. * @@ -22,12 +22,43 @@ * (12 in examples/ all behind dryRun OR an authorIs mod-bypass filter). */ +/** + * YAML rendering of the same default config for the config editor's seed + * template. Derived from DEFAULT_CONFIG_JSON5 by parsing with json5 then + * dumping with js-yaml so both exports stay in sync with the same logical + * content. YAML is the default editor format (FoxxMD Discord 2026-05-20: + * "most mods use [YAML] since it's the same syntax as automod"). + * + * The leading comment block tells a new mod what the file is + what to do. + */ +export const DEFAULT_CONFIG_YAML = `# ContextMod config. Edit this wiki page to customise your rules. +# Full reference: https://github.com/FoxxMD/context-mod +# +# dryRun: true means actions are SIMULATED, not executed. +# Watch the Observatory dashboard for a day, then set dryRun: false. + +dryRun: true +runs: + - name: starter + checks: + - name: crypto-giveaway-spam + combinator: OR + rules: + - kind: regex + name: scam-words + pattern: 'scam|giveaway|free crypto' + flags: i + actions: + - kind: remove + isSpam: true +`; + export const DEFAULT_CONFIG_JSON5 = `{ // Default config seeded on install. Edit r//wiki/botconfig/contextmod // to replace, then click "ContextMod: Reload config from wiki" in the mod // menu (or wait up to 5 min for the cron to pick it up). // - // IMPORTANT: dryRun:true means actions are SIMULATED, not executed — + // IMPORTANT: dryRun:true means actions are SIMULATED, not executed. // dashboard shows what would have happened. Flip to false in the wiki // when you trust the rule's judgment (recommend: watch for ~1 day first). dryRun: true, diff --git a/src/routes/api.ts b/src/routes/api.ts index 8bbfbd4..08f17cc 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -29,9 +29,12 @@ import { checkCircuit, recordFailure, recordSuccess } from '../lib/circuitBreake import { log } from '../lib/log'; import { isTransientOpenaiError } from '../lib/openaiErrors'; import { readStatsSnapshot } from '../state/statsRollup'; +import { configEditor } from './configEditor'; export const api = new Hono(); +api.route('/config', configEditor); + api.get('/recent', async (c) => { if (c.req.query('demo') === '1') { log.info('cm/api/recent', 'demo=1 — serving synthetic fixtures (not real ZSET)'); diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts new file mode 100644 index 0000000..42dc31a --- /dev/null +++ b/src/routes/configEditor.ts @@ -0,0 +1,42 @@ +/** + * Config-editor route group, mounted at /api/config (Task 3). + * + * GET /raw: returns the current wiki page content + revisionId so the + * editor client can seed its CodeMirror buffer. On a fresh sub + * with no wiki page yet, returns the default YAML template so + * the mod starts from something valid rather than a blank slate. + * Auth-gated: mods only. + * + * Later tasks will add /validate, /simulate-live, /explain, /save to this + * same sub-app. + */ + +import { Hono } from 'hono'; +import { reddit } from '@devvit/web/server'; +import { WIKI_PAGE } from '../core/configSource'; +import { requireModerator } from '../lib/requireModerator'; +import { DEFAULT_CONFIG_YAML } from '../config/default-config'; +import { log } from '../lib/log'; + +export const configEditor = new Hono(); + +function isNotFound(err: unknown): boolean { + const m = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return m.includes('not found') || m.includes('404') || m.includes('does not exist') || m.includes('no such page'); +} + +configEditor.get('/raw', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ error: auth.error }, auth.status); + try { + const page = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + return c.json({ content: page.content, revisionId: page.revisionId, isDefaultTemplate: false }); + } catch (err) { + if (isNotFound(err)) { + return c.json({ content: DEFAULT_CONFIG_YAML, revisionId: null, isDefaultTemplate: true }); + } + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/raw', 'wiki read failed', { err: msg, sub: auth.sub }); + return c.json({ error: `wiki unavailable: ${msg}` }, 503); + } +}); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts new file mode 100644 index 0000000..9c065a8 --- /dev/null +++ b/tests/routes/config-editor.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getWikiPage = vi.fn(); +const requireModeratorMock = vi.fn(); +vi.mock('@devvit/web/server', () => ({ + reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, + redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK') }, + settings: { get: vi.fn() }, +})); +vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); + +import { configEditor } from '../../src/routes/configEditor'; + +const MOD = { ok: true, sub: 'testsub', username: 'mod1' }; +const NON_MOD = { ok: false, status: 403, error: 'not a moderator of this sub' }; + +async function get(path: string) { + const res = await configEditor.request(new Request(`http://x${path}`)); + return { status: res.status, body: await res.json() }; +} + +describe('GET /raw', () => { + beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); + + it('returns wiki content + revisionId for a mod', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-1' }); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }); + }); + + it('rejects non-mods', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await get('/raw'); + expect(r.status).toBe(403); + }); + + it('returns the default template on a missing page', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('404 not found')); + const r = await get('/raw'); + expect(r.status).toBe(200); + expect(r.body.isDefaultTemplate).toBe(true); + expect(typeof r.body.content).toBe('string'); + }); + + it('returns 503 on a non-404 wiki error', async () => { + requireModeratorMock.mockResolvedValue(MOD); + getWikiPage.mockRejectedValue(new Error('internal error')); + const r = await get('/raw'); + expect(r.status).toBe(503); + }); +}); From e1def8913ced45923c9858bb80b67d6386d50987 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:51:52 -0400 Subject: [PATCH 06/21] feat(api): POST /api/config/validate runs parseConfig + AJV Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/configEditor.ts | 11 +++++++++++ tests/routes/config-editor.test.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts index 42dc31a..5bab987 100644 --- a/src/routes/configEditor.ts +++ b/src/routes/configEditor.ts @@ -17,6 +17,7 @@ import { WIKI_PAGE } from '../core/configSource'; import { requireModerator } from '../lib/requireModerator'; import { DEFAULT_CONFIG_YAML } from '../config/default-config'; import { log } from '../lib/log'; +import { parseConfig } from '../core/config'; export const configEditor = new Hono(); @@ -40,3 +41,13 @@ configEditor.get('/raw', async (c) => { return c.json({ error: `wiki unavailable: ${msg}` }, 503); } }); + +configEditor.post('/validate', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string') return c.json({ ok: false, error: 'text required' }, 400); + const parsed = parseConfig(text); + if (parsed.ok) return c.json({ ok: true, format: parsed.format }); + return c.json({ ok: false, errors: parsed.errors }); +}); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts index 9c065a8..50cb607 100644 --- a/tests/routes/config-editor.test.ts +++ b/tests/routes/config-editor.test.ts @@ -2,12 +2,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const getWikiPage = vi.fn(); const requireModeratorMock = vi.fn(); +const getRecentSample = vi.fn(); +const simulateRule = vi.fn(); +const explainRule = vi.fn(); +const resolveOpenaiKey = vi.fn(); vi.mock('@devvit/web/server', () => ({ reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, - redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK') }, + redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK'), incrBy: vi.fn(async () => 1), expire: vi.fn(async () => undefined) }, settings: { get: vi.fn() }, })); vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); +vi.mock('../../src/core/recentSample', () => ({ getRecentSample: (s: string) => getRecentSample(s) })); +vi.mock('../../src/core/simulateRule', () => ({ simulateRule: (...a: unknown[]) => simulateRule(...a) })); +vi.mock('../../src/core/explainRule', () => ({ explainRule: (...a: unknown[]) => explainRule(...a) })); +vi.mock('../../src/lib/resolveOpenaiKey', () => ({ resolveOpenaiKey: (s: string) => resolveOpenaiKey(s) })); import { configEditor } from '../../src/routes/configEditor'; @@ -19,6 +27,26 @@ async function get(path: string) { return { status: res.status, body: await res.json() }; } +async function post(path: string, body: unknown) { + const res = await configEditor.request(new Request(`http://x${path}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +describe('POST /validate', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); }); + it('accepts a valid config', async () => { + const r = await post('/validate', { text: 'runs: []' }); + expect(r.body.ok).toBe(true); + }); + it('reports errors for an invalid config', async () => { + const r = await post('/validate', { text: 'runs:\n kind: badvalue' }); + expect(r.body.ok).toBe(false); + expect(r.body.errors).toBeDefined(); + }); +}); + describe('GET /raw', () => { beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); From 9f21ee6d7a9f45787e74bd0b46b736bf6be6bff0 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:52:46 -0400 Subject: [PATCH 07/21] feat(api): POST /api/config/simulate-live (dry-run impact on cached sample) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/configEditor.ts | 17 +++++++++++++++++ tests/routes/config-editor.test.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts index 5bab987..4b6462b 100644 --- a/src/routes/configEditor.ts +++ b/src/routes/configEditor.ts @@ -18,6 +18,9 @@ import { requireModerator } from '../lib/requireModerator'; import { DEFAULT_CONFIG_YAML } from '../config/default-config'; import { log } from '../lib/log'; import { parseConfig } from '../core/config'; +import { getRecentSample } from '../core/recentSample'; +import { simulateRule } from '../core/simulateRule'; +import { checkRateLimit } from '../lib/ratelimit'; export const configEditor = new Hono(); @@ -51,3 +54,17 @@ configEditor.post('/validate', async (c) => { if (parsed.ok) return c.json({ ok: true, format: parsed.format }); return c.json({ ok: false, errors: parsed.errors }); }); + +configEditor.post('/simulate-live', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + const rl = await checkRateLimit('simulate-live', auth.sub, 120, 60); + if (!rl.allowed) return c.json({ ok: false, error: 'Slow down a moment, then keep editing.' }, 429); + const samples = await getRecentSample(auth.sub); + const result = await simulateRule(text, samples, auth.sub); + return c.json(result); +}); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts index 50cb607..001f57a 100644 --- a/tests/routes/config-editor.test.ts +++ b/tests/routes/config-editor.test.ts @@ -47,6 +47,15 @@ describe('POST /validate', () => { }); }); +describe('POST /simulate-live', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); getRecentSample.mockResolvedValue([]); }); + it('returns the simulation result', async () => { + simulateRule.mockResolvedValue({ ok: true, totalSamples: 25, firedCount: 7, erroredCount: 0, breakdown: [] }); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, firedCount: 7, totalSamples: 25 }); + }); +}); + describe('GET /raw', () => { beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); From 86a6a3de108bdbbf571d1c957489792b3a9be20c Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:53:54 -0400 Subject: [PATCH 08/21] feat(api): POST /api/config/explain (AI explainer for the editor) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/configEditor.ts | 19 +++++++++++++++++++ tests/routes/config-editor.test.ts | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts index 4b6462b..c0b6b0c 100644 --- a/src/routes/configEditor.ts +++ b/src/routes/configEditor.ts @@ -21,6 +21,8 @@ import { parseConfig } from '../core/config'; import { getRecentSample } from '../core/recentSample'; import { simulateRule } from '../core/simulateRule'; import { checkRateLimit } from '../lib/ratelimit'; +import { explainRule } from '../core/explainRule'; +import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; export const configEditor = new Hono(); @@ -68,3 +70,20 @@ configEditor.post('/simulate-live', async (c) => { const result = await simulateRule(text, samples, auth.sub); return c.json(result); }); + +configEditor.post('/explain', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + const { text } = await c.req.json<{ text?: string }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + const rl = await checkRateLimit('explain', `${auth.sub}:${auth.username}`, 10, 3600); + if (rl.degraded) return c.json({ ok: false, error: 'Rate-limit subsystem degraded. Retry in ~60s.' }, 503); + if (!rl.allowed) return c.json({ ok: false, error: `Your limit: ${rl.count}/${rl.max} this hour.` }, 429); + const apiKey = await resolveOpenaiKey(auth.sub); + if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); + const result = await explainRule(text, apiKey); + if (!result.ok) return c.json({ ok: false, error: result.error }, 500); + return c.json({ ok: true, explanation: result.value }); +}); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts index 001f57a..8d32f2f 100644 --- a/tests/routes/config-editor.test.ts +++ b/tests/routes/config-editor.test.ts @@ -56,6 +56,15 @@ describe('POST /simulate-live', () => { }); }); +describe('POST /explain', () => { + beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); resolveOpenaiKey.mockResolvedValue('sk-x'); }); + it('returns the explanation', async () => { + explainRule.mockResolvedValue({ ok: true, value: 'This rule removes crypto spam.' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.body).toMatchObject({ ok: true, explanation: 'This rule removes crypto spam.' }); + }); +}); + describe('GET /raw', () => { beforeEach(() => { getWikiPage.mockReset(); requireModeratorMock.mockReset(); }); From 6e27d6a34d1c1e1a70595bfa1f0ad130f1d76680 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 18:59:52 -0400 Subject: [PATCH 09/21] test(api): error-branch coverage + structured 503 for config editor endpoints Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/configEditor.ts | 26 ++++++-- tests/routes/config-editor.test.ts | 100 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 10 deletions(-) diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts index c0b6b0c..f5b74e0 100644 --- a/src/routes/configEditor.ts +++ b/src/routes/configEditor.ts @@ -66,8 +66,14 @@ configEditor.post('/simulate-live', async (c) => { } const rl = await checkRateLimit('simulate-live', auth.sub, 120, 60); if (!rl.allowed) return c.json({ ok: false, error: 'Slow down a moment, then keep editing.' }, 429); - const samples = await getRecentSample(auth.sub); - const result = await simulateRule(text, samples, auth.sub); + let result; + try { + const samples = await getRecentSample(auth.sub); + result = await simulateRule(text, samples, auth.sub); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Simulation unavailable: ${msg}` }, 503); + } return c.json(result); }); @@ -78,12 +84,18 @@ configEditor.post('/explain', async (c) => { if (typeof text !== 'string' || text.length > 100_000) { return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); } + // TODO: per-sub circuit breaker (v2); per-user cap is the v1 cost gate. const rl = await checkRateLimit('explain', `${auth.sub}:${auth.username}`, 10, 3600); if (rl.degraded) return c.json({ ok: false, error: 'Rate-limit subsystem degraded. Retry in ~60s.' }, 503); if (!rl.allowed) return c.json({ ok: false, error: `Your limit: ${rl.count}/${rl.max} this hour.` }, 429); - const apiKey = await resolveOpenaiKey(auth.sub); - if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); - const result = await explainRule(text, apiKey); - if (!result.ok) return c.json({ ok: false, error: result.error }, 500); - return c.json({ ok: true, explanation: result.value }); + try { + const apiKey = await resolveOpenaiKey(auth.sub); + if (!apiKey) return c.json({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }, 400); + const result = await explainRule(text, apiKey); + if (!result.ok) return c.json({ ok: false, error: result.error }, 500); + return c.json({ ok: true, explanation: result.value }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Explain unavailable: ${msg}` }, 503); + } }); diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts index 8d32f2f..c906b8f 100644 --- a/tests/routes/config-editor.test.ts +++ b/tests/routes/config-editor.test.ts @@ -6,9 +6,21 @@ const getRecentSample = vi.fn(); const simulateRule = vi.fn(); const explainRule = vi.fn(); const resolveOpenaiKey = vi.fn(); + +// Named redis fn handles so individual tests can override per-call behavior. +const redisGet = vi.fn(async () => null); +const redisSet = vi.fn(async () => 'OK'); +const redisIncrBy = vi.fn(async () => 1); +const redisExpire = vi.fn(async () => undefined); + vi.mock('@devvit/web/server', () => ({ reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, - redis: { get: vi.fn(async () => null), set: vi.fn(async () => 'OK'), incrBy: vi.fn(async () => 1), expire: vi.fn(async () => undefined) }, + redis: { + get: (...args: unknown[]) => redisGet(...args), + set: (...args: unknown[]) => redisSet(...args), + incrBy: (...args: unknown[]) => redisIncrBy(...args), + expire: (...args: unknown[]) => redisExpire(...args), + }, settings: { get: vi.fn() }, })); vi.mock('../../src/lib/requireModerator', () => ({ requireModerator: () => requireModeratorMock() })); @@ -45,24 +57,106 @@ describe('POST /validate', () => { expect(r.body.ok).toBe(false); expect(r.body.errors).toBeDefined(); }); + it('rejects non-mods with 403', async () => { + requireModeratorMock.mockResolvedValue(NON_MOD); + const r = await post('/validate', { text: 'runs: []' }); + expect(r.status).toBe(403); + expect(r.body.ok).toBe(false); + }); }); describe('POST /simulate-live', () => { - beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); getRecentSample.mockResolvedValue([]); }); + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + getRecentSample.mockResolvedValue([]); + // Default: incrBy returns 1 (well under 120 cap) — allowed. + redisIncrBy.mockResolvedValue(1); + }); + it('returns the simulation result', async () => { simulateRule.mockResolvedValue({ ok: true, totalSamples: 25, firedCount: 7, erroredCount: 0, breakdown: [] }); const r = await post('/simulate-live', { text: 'runs: []' }); expect(r.body).toMatchObject({ ok: true, firedCount: 7, totalSamples: 25 }); }); + + it('returns 400 when text is missing', async () => { + const r = await post('/simulate-live', {}); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + }); + + it('returns 429 when rate-limited (incrBy exceeds cap of 120)', async () => { + // checkRateLimit: allowed = count <= max; 121 > 120, so allowed = false. + redisIncrBy.mockResolvedValueOnce(121); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.status).toBe(429); + expect(r.body.ok).toBe(false); + }); + + it('returns 503 when getRecentSample throws', async () => { + getRecentSample.mockRejectedValueOnce(new Error('Redis timeout')); + simulateRule.mockResolvedValue({ ok: true, totalSamples: 0, firedCount: 0, erroredCount: 0, breakdown: [] }); + const r = await post('/simulate-live', { text: 'runs: []' }); + expect(r.status).toBe(503); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/Simulation unavailable/); + }); }); describe('POST /explain', () => { - beforeEach(() => { requireModeratorMock.mockResolvedValue(MOD); resolveOpenaiKey.mockResolvedValue('sk-x'); }); + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + resolveOpenaiKey.mockResolvedValue('sk-x'); + // Default: incrBy returns 1 (under 10 cap) — allowed, not degraded. + redisIncrBy.mockResolvedValue(1); + }); + it('returns the explanation', async () => { explainRule.mockResolvedValue({ ok: true, value: 'This rule removes crypto spam.' }); const r = await post('/explain', { text: 'runs: []' }); expect(r.body).toMatchObject({ ok: true, explanation: 'This rule removes crypto spam.' }); }); + + it('returns 400 when text is missing', async () => { + const r = await post('/explain', {}); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + }); + + it('returns 400 when no OpenAI key is set', async () => { + resolveOpenaiKey.mockResolvedValueOnce(''); + explainRule.mockResolvedValue({ ok: true, value: 'irrelevant' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(400); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/No OpenAI key/); + }); + + it('returns 503 when rate-limit subsystem is degraded (incrBy throws)', async () => { + // checkRateLimit catches the throw and returns { allowed: true, degraded: true }. + // The /explain handler checks rl.degraded before rl.allowed and returns 503. + redisIncrBy.mockRejectedValueOnce(new Error('Redis connection refused')); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(503); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/degraded/i); + }); + + it('returns 429 when rate-limited (incrBy exceeds cap of 10)', async () => { + // checkRateLimit: allowed = count <= max; 11 > 10, so allowed = false. + redisIncrBy.mockResolvedValueOnce(11); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(429); + expect(r.body.ok).toBe(false); + expect(r.body.error).toMatch(/limit/i); + }); + + it('returns 500 when explainRule returns {ok: false}', async () => { + explainRule.mockResolvedValueOnce({ ok: false, error: 'OpenAI returned 500' }); + const r = await post('/explain', { text: 'runs: []' }); + expect(r.status).toBe(500); + expect(r.body.ok).toBe(false); + }); }); describe('GET /raw', () => { From 1dbdf0d18fba69ba6234db9ce7e551a8cab3668e Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 19:04:41 -0400 Subject: [PATCH 10/21] feat(api): POST /api/config/save with validation gate + optimistic lock Implements Task 7. Validation gate (parseConfig) runs before any wiki write so an invalid config can never reach the live moderation wiki. Optimistic lock re-reads the current wiki revisionId before writing and returns 409 if the page moved since the editor loaded, preventing silent concurrent-edit clobbers. On success: updateWikiPage, publish to Redis, best-effort stamp cfgLastWikiRev, logModActivity(edit-config). Added 'edit-config' to ModActivityKind union and the isValidModActivity allow- list. Test mock extended: updateWikiPage now a named vi.fn(); new vi.mock blocks for configStore and modActivity. 3 new tests (400/409/200) green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/routes/configEditor.ts | 80 +++++++++++++++++++++++++++++- src/state/modActivity.ts | 4 +- tests/routes/config-editor.test.ts | 50 ++++++++++++++++++- 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/src/routes/configEditor.ts b/src/routes/configEditor.ts index f5b74e0..e883e1e 100644 --- a/src/routes/configEditor.ts +++ b/src/routes/configEditor.ts @@ -12,7 +12,7 @@ */ import { Hono } from 'hono'; -import { reddit } from '@devvit/web/server'; +import { reddit, redis } from '@devvit/web/server'; import { WIKI_PAGE } from '../core/configSource'; import { requireModerator } from '../lib/requireModerator'; import { DEFAULT_CONFIG_YAML } from '../config/default-config'; @@ -23,6 +23,9 @@ import { simulateRule } from '../core/simulateRule'; import { checkRateLimit } from '../lib/ratelimit'; import { explainRule } from '../core/explainRule'; import { resolveOpenaiKey } from '../lib/resolveOpenaiKey'; +import { publish } from '../state/configStore'; +import { K } from '../state/keys'; +import { logModActivity } from '../state/modActivity'; export const configEditor = new Hono(); @@ -99,3 +102,78 @@ configEditor.post('/explain', async (c) => { return c.json({ ok: false, error: `Explain unavailable: ${msg}` }, 503); } }); + +configEditor.post('/save', async (c) => { + const auth = await requireModerator(); + if (!auth.ok) return c.json({ ok: false, error: auth.error }, auth.status); + + const { text, baseRevisionId } = await c.req.json<{ + text?: string; + baseRevisionId?: string | null; + }>(); + if (typeof text !== 'string' || text.length > 100_000) { + return c.json({ ok: false, error: 'text required (max 100KB)' }, 400); + } + + // Gate 1: never write an invalid config to the live moderation wiki. + const parsed = parseConfig(text); + if (!parsed.ok) return c.json({ ok: false, error: 'config invalid', errors: parsed.errors }, 400); + + // Gate 2: optimistic lock. Re-read the current wiki rev; if it moved since + // the editor loaded, refuse so we never silently clobber a concurrent edit. + try { + const current = await reddit.getWikiPage(auth.sub, WIKI_PAGE); + if (baseRevisionId && current.revisionId !== baseRevisionId) { + return c.json( + { + ok: false, + error: 'The wiki changed since you opened the editor. Reload to merge.', + conflict: true, + }, + 409 + ); + } + } catch (err) { + if (!isNotFound(err)) { + const errMsg = err instanceof Error ? err.message : String(err); + return c.json({ ok: false, error: `Could not verify current wiki state: ${errMsg}` }, 503); + } + // not-found means the wiki page does not exist yet; allow the first-ever create. + } + + let written; + try { + written = await reddit.updateWikiPage({ + subredditName: auth.sub, + page: WIKI_PAGE, + content: text, + reason: `Edited via ContextMod Observatory by u/${auth.username}`, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error('cm/api/config/save', 'updateWikiPage failed', { err: msg, sub: auth.sub }); + return c.json({ ok: false, error: `Wiki write failed: ${msg}` }, 502); + } + + const rev = await publish(parsed.config, auth.sub); + + // stamp the new wiki rev so the 5-min cron does not re-publish an identical config (best-effort). + try { + if (written?.revisionId) await redis.set(K.cfgLastWikiRev(auth.sub), written.revisionId); + } catch { + /* stamping is best-effort; the cron self-heals next tick */ + } + + const ruleCount = parsed.config.runs + .flatMap((r) => r.checks) + .flatMap((ch) => ch.rules).length; + + await logModActivity(auth.sub, { + ts: Date.now(), + actor: auth.username, + kind: 'edit-config', + detail: `${ruleCount} rules @ rev ${rev}`, + }); + + return c.json({ ok: true, rev, ruleCount }); +}); diff --git a/src/state/modActivity.ts b/src/state/modActivity.ts index ef2e9a7..fbd5560 100644 --- a/src/state/modActivity.ts +++ b/src/state/modActivity.ts @@ -18,7 +18,8 @@ export type ModActivityKind = | 'simulate-rule' | 'explain-rule' | 'mute-rule' - | 'unmute-rule'; + | 'unmute-rule' + | 'edit-config'; export interface ModActivity { ts: number; @@ -53,6 +54,7 @@ function isValidModActivity(o: unknown): o is ModActivity { 'explain-rule', 'mute-rule', 'unmute-rule', + 'edit-config', ]; if (!valid.includes(m.kind as ModActivityKind)) return false; if (m.detail !== undefined && typeof m.detail !== 'string') return false; diff --git a/tests/routes/config-editor.test.ts b/tests/routes/config-editor.test.ts index c906b8f..b5ba925 100644 --- a/tests/routes/config-editor.test.ts +++ b/tests/routes/config-editor.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const getWikiPage = vi.fn(); +const updateWikiPage = vi.fn(); const requireModeratorMock = vi.fn(); const getRecentSample = vi.fn(); const simulateRule = vi.fn(); const explainRule = vi.fn(); const resolveOpenaiKey = vi.fn(); +const publish = vi.fn(); +const logModActivity = vi.fn(); // Named redis fn handles so individual tests can override per-call behavior. const redisGet = vi.fn(async () => null); @@ -14,7 +17,10 @@ const redisIncrBy = vi.fn(async () => 1); const redisExpire = vi.fn(async () => undefined); vi.mock('@devvit/web/server', () => ({ - reddit: { getWikiPage: (s: string, p: string) => getWikiPage(s, p), updateWikiPage: vi.fn() }, + reddit: { + getWikiPage: (s: string, p: string) => getWikiPage(s, p), + updateWikiPage: (o: unknown) => updateWikiPage(o), + }, redis: { get: (...args: unknown[]) => redisGet(...args), set: (...args: unknown[]) => redisSet(...args), @@ -28,6 +34,14 @@ vi.mock('../../src/core/recentSample', () => ({ getRecentSample: (s: string) => vi.mock('../../src/core/simulateRule', () => ({ simulateRule: (...a: unknown[]) => simulateRule(...a) })); vi.mock('../../src/core/explainRule', () => ({ explainRule: (...a: unknown[]) => explainRule(...a) })); vi.mock('../../src/lib/resolveOpenaiKey', () => ({ resolveOpenaiKey: (s: string) => resolveOpenaiKey(s) })); +vi.mock('../../src/state/configStore', () => ({ + publish: (...a: unknown[]) => publish(...a), + getCurrentRev: vi.fn(), + getRecentRevs: vi.fn(), +})); +vi.mock('../../src/state/modActivity', () => ({ + logModActivity: (...a: unknown[]) => logModActivity(...a), +})); import { configEditor } from '../../src/routes/configEditor'; @@ -192,3 +206,37 @@ describe('GET /raw', () => { expect(r.status).toBe(503); }); }); + +describe('POST /save', () => { + beforeEach(() => { + requireModeratorMock.mockResolvedValue(MOD); + updateWikiPage.mockReset(); + publish.mockReset(); + getWikiPage.mockReset(); + logModActivity.mockReset(); + }); + + it('rejects an invalid config without writing', async () => { + const r = await post('/save', { text: 'runs: "bad"', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(400); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('409s when the wiki moved since load', async () => { + getWikiPage.mockResolvedValue({ content: 'runs: []', revisionId: 'rev-2' }); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(409); + expect(updateWikiPage).not.toHaveBeenCalled(); + }); + + it('saves a valid config and publishes', async () => { + getWikiPage.mockResolvedValue({ content: 'old', revisionId: 'rev-1' }); + updateWikiPage.mockResolvedValue({ revisionId: 'rev-2' }); + publish.mockResolvedValue(0); + const r = await post('/save', { text: 'runs: []', baseRevisionId: 'rev-1' }); + expect(r.status).toBe(200); + expect(updateWikiPage).toHaveBeenCalledOnce(); + expect(publish).toHaveBeenCalledOnce(); + expect(r.body).toMatchObject({ ok: true, rev: 0, ruleCount: 0 }); + }); +}); From 57af13de26a1e24ff1e34887d3e6732908a86380 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 19:35:02 -0400 Subject: [PATCH 11/21] build: add CodeMirror 6 + codemirror-json-schema deps Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 1018 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 9 + 2 files changed, 1025 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index e63902c..b2b30d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,18 @@ "version": "0.6.7", "license": "MIT", "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", "@hono/node-server": "^2.0.3", "ajv": "^8.17.1", "blockhash-core": "^0.1.0", + "codemirror-json-schema": "^0.8.1", "hono": "^4.12.21", "jpeg-js": "^0.4.4", "js-yaml": "^4.1.1", @@ -527,6 +536,101 @@ "specificity": "bin/cli.js" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz", + "integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@commitlint/cli": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.0.1.tgz", @@ -3074,6 +3178,58 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -4066,6 +4222,22 @@ "win32" ] }, + "node_modules/@sagold/json-pointer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@sagold/json-pointer/-/json-pointer-5.1.2.tgz", + "integrity": "sha512-+wAhJZBXa6MNxRScg6tkqEbChEHMgVZAhTHVJ60Y7sbtXtu9XA49KfUkdWlS2x78D6H9nryiKePiYozumauPfA==", + "license": "MIT" + }, + "node_modules/@sagold/json-query": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@sagold/json-query/-/json-query-6.2.0.tgz", + "integrity": "sha512-7bOIdUE6eHeoWtFm8TvHQHfTVSZuCs+3RpOKmZCDBIOrxpvF/rNFTeuvIyjHva/RR0yVS3kQtr+9TW72LQEZjA==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.2", + "ebnf": "^1.9.1" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -4073,6 +4245,85 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.29.2.tgz", + "integrity": "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz", + "integrity": "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "oniguruma-to-es": "^2.2.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz", + "integrity": "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1" + } + }, + "node_modules/@shikijs/langs": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-1.29.2.tgz", + "integrity": "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/markdown-it": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/markdown-it/-/markdown-it-1.29.2.tgz", + "integrity": "sha512-RPHqGU8RGQZ2TGMnEqLnSyM9CjPSjb0f8bwSLnJgBmWPWguoygoaFyYkXG0kwMtBtChNYsqQz1C0fLcbo6dY8g==", + "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.0", + "shiki": "1.29.2" + } + }, + "node_modules/@shikijs/themes": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-1.29.2.tgz", + "integrity": "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "1.29.2" + } + }, + "node_modules/@shikijs/types": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.29.2.tgz", + "integrity": "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@simple-libs/child-process-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", @@ -4298,6 +4549,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -4312,6 +4572,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mustache": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz", @@ -4356,6 +4625,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", @@ -4651,6 +4926,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -5139,6 +5420,12 @@ "node": ">=6.0.0" } }, + "node_modules/best-effort-json-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/best-effort-json-parser/-/best-effort-json-parser-1.4.1.tgz", + "integrity": "sha512-1e2yvg4LieOlqDw7rAOvHetMJeJ2sGezLX/2Ma0oykFd8TMfMxdDBzNUXjISPeMZAHe5CCvPRJtHmISoTC6MsA==", + "license": "BSD-2-Clause" + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -5412,6 +5699,16 @@ "cdl": "bin/cdl.js" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5456,6 +5753,26 @@ "node": ">=12.20" } }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -5701,6 +6018,53 @@ "node": ">=6" } }, + "node_modules/codemirror-json-schema": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/codemirror-json-schema/-/codemirror-json-schema-0.8.1.tgz", + "integrity": "sha512-4lKPjW+nugNAmM5MsggJyn6TUxYdCCwAJIr9T4cZeTFPdkbBvPteCOGtDedrTOIeTC2ZFJtVg7VHIXnYU32t8w==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.1", + "@shikijs/markdown-it": "^1.22.2", + "best-effort-json-parser": "^1.1.2", + "json-schema": "^0.4.0", + "json-schema-library": "^9.3.5", + "loglevel": "^1.9.1", + "markdown-it": "^14.1.0", + "shiki": "^1.22.2", + "yaml": "^2.3.4" + }, + "optionalDependencies": { + "@codemirror/autocomplete": "^6.16.2", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-yaml": "^6.1.1", + "codemirror-json5": "^1.0.3", + "json5": "^2.2.3" + }, + "peerDependencies": { + "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.2.1" + } + }, + "node_modules/codemirror-json5": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/codemirror-json5/-/codemirror-json5-1.0.3.tgz", + "integrity": "sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "json5": "^2.2.1", + "lezer-json5": "^2.0.2" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5734,6 +6098,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -5923,6 +6297,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6215,6 +6595,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -6377,9 +6766,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -6394,6 +6781,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/devvit": { "version": "0.12.24", "resolved": "https://registry.npmjs.org/devvit/-/devvit-0.12.24.tgz", @@ -6444,6 +6844,12 @@ "node": ">=8" } }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -6521,6 +6927,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ebnf": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ebnf/-/ebnf-1.9.1.tgz", + "integrity": "sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==", + "license": "MIT", + "bin": { + "ebnf": "dist/bin.js" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6551,6 +6966,12 @@ "dev": true, "license": "MIT" }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.21.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", @@ -7032,6 +7453,12 @@ "node": ">=12.17.0" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7693,6 +8120,42 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hono": { "version": "4.12.21", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz", @@ -7736,6 +8199,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-call": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/http-call/-/http-call-5.3.0.tgz", @@ -8715,6 +9188,27 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-library": { + "version": "9.3.5", + "resolved": "https://registry.npmjs.org/json-schema-library/-/json-schema-library-9.3.5.tgz", + "integrity": "sha512-5eBDx7cbfs+RjylsVO+N36b0GOPtv78rfqgf2uON+uaHUIC62h63Y8pkV2ovKbaL4ZpQcHp21968x5nx/dFwqQ==", + "license": "MIT", + "dependencies": { + "@sagold/json-pointer": "^5.1.2", + "@sagold/json-query": "^6.1.3", + "deepmerge": "^4.3.1", + "fast-copy": "^3.0.2", + "fast-deep-equal": "^3.1.3", + "smtp-address-parser": "1.0.10", + "valid-url": "^1.0.9" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -8870,6 +9364,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lezer-json5": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lezer-json5/-/lezer-json5-2.0.2.tgz", + "integrity": "sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@lezer/lr": "^1.0.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -9161,6 +9665,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9234,6 +9757,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -9345,6 +9881,45 @@ "dev": true, "license": "ISC" }, + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9355,6 +9930,27 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -9362,6 +9958,12 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -9385,6 +9987,95 @@ "node": ">= 8" } }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -9520,6 +10211,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9591,6 +10288,34 @@ "node": "*" } }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -9712,6 +10437,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oniguruma-to-es": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz", + "integrity": "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==", + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^5.1.1", + "regex-recursion": "^5.1.1" + } + }, "node_modules/open": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/open/-/open-10.1.0.tgz", @@ -10436,6 +11172,16 @@ "node": ">= 6" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -10484,6 +11230,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", @@ -10529,6 +11284,25 @@ ], "license": "MIT" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10643,6 +11417,31 @@ "esprima": "~4.0.0" } }, + "node_modules/regex": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-5.1.1.tgz", + "integrity": "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-5.1.1.tgz", + "integrity": "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==", + "license": "MIT", + "dependencies": { + "regex": "^5.1.1", + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -10724,6 +11523,15 @@ "dev": true, "license": "ISC" }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -11046,6 +11854,22 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", + "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "1.29.2", + "@shikijs/engine-javascript": "1.29.2", + "@shikijs/engine-oniguruma": "1.29.2", + "@shikijs/langs": "1.29.2", + "@shikijs/themes": "1.29.2", + "@shikijs/types": "1.29.2", + "@shikijs/vscode-textmate": "^10.0.1", + "@types/hast": "^3.0.4" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -11148,6 +11972,18 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smtp-address-parser": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz", + "integrity": "sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==", + "license": "MIT", + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -11158,6 +11994,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -11282,6 +12128,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11362,6 +12222,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -11910,6 +12776,16 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -12178,6 +13054,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -12221,6 +13103,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -12320,6 +13270,39 @@ "dev": true, "license": "MIT" }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", @@ -13507,6 +14490,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -13790,6 +14779,21 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", @@ -13907,6 +14911,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 61e40d0..e29c4e1 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,18 @@ "node": "^20.19.0 || >=22.12.0" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.3", + "@codemirror/lint": "^6.9.6", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.43.0", "@hono/node-server": "^2.0.3", "ajv": "^8.17.1", "blockhash-core": "^0.1.0", + "codemirror-json-schema": "^0.8.1", "hono": "^4.12.21", "jpeg-js": "^0.4.4", "js-yaml": "^4.1.1", From 3afe43c6dfe61c38e338393444f2ca8398b330da Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 19:36:07 -0400 Subject: [PATCH 12/21] feat(client): config editor API helpers (raw/validate/simulate/explain/save) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/lib/api.ts | 70 ++++++++++++++++++++++++++++++++- src/client/lib/types.ts | 4 ++ tests/client/config-api.test.ts | 69 ++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 tests/client/config-api.test.ts diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index 55e1d9e..af80ad1 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -1,4 +1,4 @@ -import type { ApiResult, EventRecord, StatsRollup } from './types'; +import type { ApiResult, EventRecord, StatsRollup, ConfigRaw, SaveResult, SimResult } from './types'; /** * fetchRecentSafe / fetchStatsSafe — return a discriminated ApiResult so the @@ -91,3 +91,71 @@ export const ZERO_STATS: StatsRollup = { import { demoEvents, DEMO_STATS as SHARED_DEMO_STATS } from '../../lib/demo-fixtures'; export const DEMO_EVENTS: EventRecord[] = demoEvents() as EventRecord[]; export const DEMO_STATS: StatsRollup = SHARED_DEMO_STATS as StatsRollup; + +// --------------------------------------------------------------------------- +// Config editor API helpers (Task 9) +// Follow the same ApiResult + extractServerError + demoSuffix pattern. +// --------------------------------------------------------------------------- + +export async function fetchConfigRawSafe(): Promise> { + try { + const res = await fetch(`/api/config/raw${demoSuffix()}`); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as ConfigRaw; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function validateConfigSafe(text: string): Promise<{ ok: boolean; errors?: unknown }> { + try { + const res = await fetch('/api/config/validate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + return (await res.json()) as { ok: boolean; errors?: unknown }; + } catch (err) { + return { ok: false, errors: err instanceof Error ? err.message : String(err) }; + } +} + +export async function simulateLiveSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/simulate-live', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + if (!res.ok) return { ok: false, error: await extractServerError(res) }; + const data = (await res.json()) as { ok: boolean; error?: string } & SimResult; + if (!data.ok) return { ok: false, error: data.error ?? 'simulation failed' }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function explainConfigSafe(text: string): Promise> { + try { + const res = await fetch('/api/config/explain', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), + }); + const data = (await res.json()) as { ok: boolean; explanation?: string; error?: string }; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? `HTTP ${res.status}` }; + return { ok: true, empty: false, data: data.explanation ?? '' }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function saveConfigSafe(text: string, baseRevisionId: string | null): Promise> { + try { + const res = await fetch('/api/config/save', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, baseRevisionId }), + }); + const data = (await res.json()) as { ok: boolean; error?: string } & SaveResult; + if (!res.ok || !data.ok) return { ok: false, error: data.error ?? `HTTP ${res.status}` }; + return { ok: true, empty: false, data }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/src/client/lib/types.ts b/src/client/lib/types.ts index a95b335..a6ce677 100644 --- a/src/client/lib/types.ts +++ b/src/client/lib/types.ts @@ -64,3 +64,7 @@ export type ApiResult = | { ok: true; empty: false; data: T } | { ok: true; empty: true } | { ok: false; error: string }; + +export type ConfigRaw = { content: string; revisionId: string | null; isDefaultTemplate: boolean }; +export type SaveResult = { rev: number; ruleCount: number }; +export type SimResult = { totalSamples: number; firedCount: number; erroredCount: number; firstError?: string }; diff --git a/tests/client/config-api.test.ts b/tests/client/config-api.test.ts new file mode 100644 index 0000000..5bd725d --- /dev/null +++ b/tests/client/config-api.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fetchConfigRawSafe, saveConfigSafe, simulateLiveSafe, explainConfigSafe } from '../../src/client/lib/api'; + +beforeEach(() => { vi.restoreAllMocks(); }); + +describe('config api helpers', () => { + it('fetchConfigRawSafe returns content on 200', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data.content).toBe('runs: []'); + }); + + it('saveConfigSafe surfaces a 409 conflict', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'wiki changed', conflict: true }), + { status: 409, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(false); + }); + + it('saveConfigSafe success returns rev and ruleCount', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, rev: 2, ruleCount: 3 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await saveConfigSafe('runs: []', 'rev-1'); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) { + expect(r.data.rev).toBe(2); + expect(r.data.ruleCount).toBe(3); + } + }); + + it('simulateLiveSafe body-level failure (200 ok:false)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'bad rule' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await simulateLiveSafe('runs: []'); + expect(r.ok).toBe(false); + }); + + it('fetchConfigRawSafe 503 returns ok:false', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'wiki unavailable' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }))); + const r = await fetchConfigRawSafe(); + expect(r.ok).toBe(false); + }); + + it('explainConfigSafe no-key 400 returns ok:false with OpenAI key message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: false, error: 'No OpenAI key set. Use the "Set OpenAI API key" mod menu.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }))); + const r = await explainConfigSafe('runs: []'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/OpenAI key/); + }); + + it('explainConfigSafe success returns explanation string', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ ok: true, explanation: 'Removes crypto spam.' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }))); + const r = await explainConfigSafe('runs: []'); + expect(r.ok).toBe(true); + if (r.ok && !r.empty) expect(r.data).toBe('Removes crypto spam.'); + }); +}); From a9785975449a21165c48c2e7605e72eaa38a7731 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 19:53:38 -0400 Subject: [PATCH 13/21] feat(client): CodeMirror 6 ConfigEditor with schema hints and value sync Implements the ConfigEditor React component wired to app.schema.json for YAML/JSON autocompletion, lint markers, and hover docs via codemirror-json-schema. Adds server.deps.inline for codemirror-json-schema in vitest.config.ts to handle the package's extensionless ESM sub-imports that Node resolution cannot resolve. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/components/ConfigEditor.tsx | 112 +++++++++++++++++++++++++ tests/client/config-editor.test.tsx | 36 ++++++++ vitest.config.ts | 9 ++ 3 files changed, 157 insertions(+) create mode 100644 src/client/components/ConfigEditor.tsx create mode 100644 tests/client/config-editor.test.tsx diff --git a/src/client/components/ConfigEditor.tsx b/src/client/components/ConfigEditor.tsx new file mode 100644 index 0000000..254e91b --- /dev/null +++ b/src/client/components/ConfigEditor.tsx @@ -0,0 +1,112 @@ +/** + * ConfigEditor — CodeMirror 6 editor wired to the app JSON Schema. + * + * Provides syntax highlighting, inline lint markers, autocompletion, and hover + * documentation for both YAML and JSON formats, driven by the same + * app.schema.json that AJV uses server-side for validation. + * + * Format-change causes a full editor teardown+recreate (different language + + * schema extension). External value changes (load, reload, conflict-reload) are + * patched in via a single dispatch without recreating the editor, preserving + * undo history and cursor position when possible. + */ +import { useEffect, useRef } from 'react'; +import { EditorState } from '@codemirror/state'; +import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { yaml } from '@codemirror/lang-yaml'; +import { json } from '@codemirror/lang-json'; +import { lintGutter } from '@codemirror/lint'; +import { jsonSchema } from 'codemirror-json-schema'; +import { yamlSchema } from 'codemirror-json-schema/yaml'; +import type { JSONSchema7 } from 'json-schema'; +import appSchemaRaw from '../../schema/app.schema.json'; + +// Cast the bundled JSON to JSONSchema7 so the schema extension functions accept +// it without complaint. The schema IS draft-07 so the cast is semantically +// correct. Using `unknown` as the intermediate step is the type-safe way to +// cross runtime / declaration boundaries when the import type is `object`. +const appSchema = appSchemaRaw as unknown as JSONSchema7; + +// Devvit may enforce a strict style-src; pass the nonce if the platform +// injects one via a tag. Guard against jsdom / +// SSR where document may not have that element. +function readCspNonce(): string | undefined { + if (typeof document === 'undefined') return undefined; + return ( + (document.querySelector('meta[property="csp-nonce"]') as HTMLMetaElement | null)?.content ?? + undefined + ); +} + +export interface ConfigEditorProps { + value: string; + format: 'yaml' | 'json'; + onChange: (text: string) => void; +} + +export function ConfigEditor({ value, format, onChange }: ConfigEditorProps) { + const host = useRef(null); + const view = useRef(null); + + // Create / recreate the editor when the format changes (the language extension + // and schema extension both differ). The onChange closure is captured via a + // stable ref so that switching format doesn't re-trigger this effect. + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }); + + useEffect(() => { + if (!host.current) return; + + const schemaExt = + format === 'json' ? jsonSchema(appSchema) : yamlSchema(appSchema); + + const nonce = readCspNonce(); + + const state = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + history(), + keymap.of([...defaultKeymap, ...historyKeymap]), + format === 'json' ? json() : yaml(), + ...schemaExt, + lintGutter(), + EditorView.updateListener.of((u) => { + if (u.docChanged) onChangeRef.current(u.state.doc.toString()); + }), + ...(nonce ? [EditorView.cspNonce.of(nonce)] : []), + EditorView.theme({ '&': { height: '100%', fontSize: '13px' } }), + ], + }); + + view.current = new EditorView({ state, parent: host.current }); + + return () => { + view.current?.destroy(); + view.current = null; + }; + // Recreate only on format change. External value edits handled by the sync + // effect below. `value` and `onChange` are intentionally omitted from the + // dep array: value changes are applied via dispatch (no recreate needed), + // and onChange is captured through onChangeRef so its identity is stable. + }, [format]); + + // Sync EXTERNAL value changes (async load, reload, conflict-reload) into the + // live editor without recreating it. The identity guard prevents a feedback + // loop: a user keystroke flows out via onChange -> parent state -> value prop, + // but value already equals the current doc, so no dispatch fires. + useEffect(() => { + const v = view.current; + if (v && value !== v.state.doc.toString()) { + v.dispatch({ + changes: { from: 0, to: v.state.doc.length, insert: value }, + }); + } + }, [value]); + + return
; +} diff --git a/tests/client/config-editor.test.tsx b/tests/client/config-editor.test.tsx new file mode 100644 index 0000000..431f79d --- /dev/null +++ b/tests/client/config-editor.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +/** + * ConfigEditor tests — CodeMirror 6 component. + * + * jsdom env required: EditorView mounts a real DOM tree. CM6 v6 supports jsdom + * for unit tests; the editor creates a `.cm-editor` wrapper div and injects + * content into `.cm-content` / `.cm-line` children. + * + * Value-sync test strategy: CM6 renders each doc line as a `.cm-line` span. + * After a dispatch the DOM updates synchronously (CM6 batch-commits inside the + * same microtask as the dispatch). We query `.cm-line` text to confirm the new + * value is reflected in the editor DOM. + */ +import { describe, it, expect } from 'vitest'; +import { render, act } from '@testing-library/react'; +import { ConfigEditor } from '../../src/client/components/ConfigEditor'; + +describe('ConfigEditor', () => { + it('mounts and renders the initial document', () => { + const { container } = render( + {}} /> + ); + expect(container.querySelector('.cm-editor')).toBeTruthy(); + expect(container.textContent).toContain('runs'); + }); + + it('syncs an external value change into the editor', () => { + const { container, rerender } = render( + {}} /> + ); + act(() => { + rerender( {}} />); + }); + expect(container.textContent).toContain('[a]'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ec4e158..d042b5f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,15 @@ export default defineConfig({ include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], exclude: ['tests/e2e/**', 'tests/bench/**', '**/__snapshots__/**'], globals: false, + // codemirror-json-schema ships ESM/CJS files with extensionless sub-imports + // (e.g. `from "./features/completion"`) that Node ESM resolution rejects. + // server.deps.inline forces Vite's bundler to handle the package instead of + // native Node, where extensionless imports resolve correctly. + server: { + deps: { + inline: ['codemirror-json-schema'], + }, + }, coverage: { provider: 'v8', reporter: ['text', 'json-summary', 'html'], From d939d21ea8d9ff598f5d8a7f4440591db316c04e Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 20:06:48 -0400 Subject: [PATCH 14/21] feat(client): ConfigWorkbench + Edit config entry (lazy-loaded, expanded mode) Task 11 integration capstone. Creates the ConfigWorkbench full-screen overlay (full-width CodeMirror, no PreviewPane yet), wires the Edit config button into ActionBar with best-effort requestExpandedMode, lazy- loads the workbench in App.tsx so CodeMirror stays out of the main entry chunk, and adds Escape key support. baseRev is refreshed from the wiki after every successful save to prevent false-conflict 409 on subsequent saves in the same session. requestExpandedMode is called via a dynamic import with an unknown cast because @devvit/client exports map has no types condition, making a static import a tsc error. Build chunks: ConfigWorkbench.js 810K (CM6 isolated), client.js 115K (main entry, pre-CM6 size preserved). 882 tests green, tsc clean, lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/App.tsx | 17 ++- src/client/components/ActionBar.tsx | 29 ++++- src/client/components/ConfigWorkbench.tsx | 135 ++++++++++++++++++++++ tests/client/config-workbench.test.tsx | 72 ++++++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 src/client/components/ConfigWorkbench.tsx create mode 100644 tests/client/config-workbench.test.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index 8b8bef0..96e3013 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useEffect, useState, useCallback, useMemo, lazy, Suspense } from 'react'; import { Header } from './components/Header'; import { StatsRow } from './components/StatsRow'; import { Sparkline } from './components/Sparkline'; @@ -19,6 +19,12 @@ import { ModActivityFeed } from './components/ModActivityFeed'; import { fetchRecentSafe, fetchStatsSafe, DEMO_EVENTS, DEMO_STATS, ZERO_STATS } from './lib/api'; import type { EventRecord, StatsRollup } from './lib/types'; +// ConfigWorkbench contains CodeMirror 6 (~70KB). Lazy-load so CM6 stays in +// a separate chunk and does NOT inflate the main dashboard entry bundle. +const ConfigWorkbench = lazy(() => + import('./components/ConfigWorkbench').then((m) => ({ default: m.ConfigWorkbench })) +); + const POLL_MS = 10_000; // Demo data is OPT-IN only via ?demo=1 — production never shows fabricated mod @@ -37,6 +43,7 @@ export default function App() { const [overlayOpen, setOverlayOpen] = useState(false); const [tourOpen, setTourOpen] = useState(() => !hasSeenTour()); const [historyOpen, setHistoryOpen] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); // Y2-X56: track initial load so first paint shows shimmer skeletons // instead of the empty-state CTA (which would mislead the mod into // thinking the bot is idle when actually we just haven't fetched yet). @@ -118,6 +125,7 @@ export default function App() { handler: () => { setOverlayOpen(false); setHistoryOpen(false); + setEditorOpen(false); }, }, { @@ -282,7 +290,7 @@ export default function App() { - + setEditorOpen(true)} />
setHistoryOpen(false)} /> + {editorOpen && ( + + setEditorOpen(false)} /> + + )} {/* AE Polish #3: gate tour on !initialLoad so the modal doesn't open on top of shimmer-skeleton dashboard. Judges who haven't seen the tour got the modal at t=0 + couldn't see what it was diff --git a/src/client/components/ActionBar.tsx b/src/client/components/ActionBar.tsx index 03fb2da..98d49ae 100644 --- a/src/client/components/ActionBar.tsx +++ b/src/client/components/ActionBar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { RefreshCw, FileText, ExternalLink, Download } from 'lucide-react'; +import { RefreshCw, FileText, ExternalLink, Download, FileEdit } from 'lucide-react'; import type { EventRecord } from '../lib/types'; import { eventsToCsv, csvFilename } from '../lib/csv-export'; @@ -22,10 +22,12 @@ export function ActionBar({ subreddit, onReload, events, + onEditConfig, }: { subreddit: string; onReload: () => Promise | void; events: EventRecord[]; + onEditConfig: () => void; }) { const [busy, setBusy] = useState(false); const [flash, setFlash] = useState(null); @@ -68,6 +70,22 @@ export function ActionBar({ scheduleFlashClear(); }; + // requestExpandedMode is in @devvit/client (browser condition only; the + // package exports map has no `types` field so tsc resolves to the panic + // shim in non-browser environments). We call it via a runtime-only path: + // dynamic import + unknown cast so the static type-checker is satisfied, + // and the whole block is best-effort (catch keeps the editor opening even + // when the Devvit runtime is absent or already expanded). + const handleEdit = async (e: React.MouseEvent) => { + try { + const devvitClient = await import('@devvit/client') as unknown as { + requestExpandedMode: (event: MouseEvent, entry: string) => Promise | void; + }; + await devvitClient.requestExpandedMode(e.nativeEvent, 'default'); + } catch { /* expand is best-effort; open the editor regardless */ } + onEditConfig(); + }; + const wikiUrl = `https://www.reddit.com/r/${subreddit}/wiki/botconfig/contextmod`; const exportDisabled = events.length === 0; @@ -115,6 +133,15 @@ export function ActionBar({ )} + +
diff --git a/src/client/components/ConfigWorkbench.tsx b/src/client/components/ConfigWorkbench.tsx new file mode 100644 index 0000000..4d12e0c --- /dev/null +++ b/src/client/components/ConfigWorkbench.tsx @@ -0,0 +1,135 @@ +/** + * ConfigWorkbench — full-screen config editor overlay. + * + * Renders ConfigEditor full-width (no preview pane yet; PreviewPane is + * added in Task 13). Handles load, debounced validation, save, and + * optimistic-lock conflict detection. + * + * baseRev-refresh invariant: after a successful save the wiki revisionId + * is re-fetched (via load()) so a second save in the same session does + * not false-conflict with a 409. The /save response returns internal + * rev + ruleCount, NOT the wiki revisionId, so we cannot derive it from + * the save response alone. + */ +import { useEffect, useRef, useState } from 'react'; +import { ConfigEditor } from './ConfigEditor'; +import { fetchConfigRawSafe, validateConfigSafe, saveConfigSafe } from '../lib/api'; + +function detectFormat(text: string): 'yaml' | 'json' { + const t = text.trimStart(); + return t.startsWith('{') || t.startsWith('[') ? 'json' : 'yaml'; +} + +export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onClose: () => void }) { + const [text, setText] = useState(''); + const [format, setFormat] = useState<'yaml' | 'json'>('yaml'); + const [baseRev, setBaseRev] = useState(null); + const [valid, setValid] = useState(null); + const [status, setStatus] = useState('Loading...'); + const [saving, setSaving] = useState(false); + const [conflict, setConflict] = useState(false); + const debounce = useRef | null>(null); + + /** + * Load (and re-load) the raw config from the wiki. + * statusOverride lets a post-save call keep the "Saved" message visible + * while still refreshing baseRev to the current wiki revisionId. + */ + async function load(statusOverride?: string) { + const r = await fetchConfigRawSafe(); + if (r.ok && !r.empty) { + setText(r.data.content); + setFormat(detectFormat(r.data.content)); + setBaseRev(r.data.revisionId); + setConflict(false); + setStatus(statusOverride ?? (r.data.isDefaultTemplate ? 'New config (starter template)' : 'Loaded')); + } else { + setStatus(r.ok ? 'Empty' : `Load failed: ${r.error}`); + } + } + + useEffect(() => { void load(); }, []); // load is stable: defined inside the component, only calls setters + + useEffect(() => { + return () => { if (debounce.current) clearTimeout(debounce.current); }; + }, []); + + function onChange(next: string) { + setText(next); + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const v = await validateConfigSafe(next); + setValid(v.ok); + }, 600); + } + + async function onSave() { + if (saving || valid === false) return; + setSaving(true); + setStatus('Saving...'); + try { + const r = await saveConfigSafe(text, baseRev); + if (r.ok && !r.empty) { + // Refresh baseRev from the wiki so a subsequent save does not + // false-conflict. Keep the Saved status visible during the re-fetch. + await load(`Saved. ${r.data.ruleCount} rules live (rev ${r.data.rev}).`); + } else { + const msg = r.ok ? 'Saved' : r.error; + setStatus(`Save failed: ${msg}`); + if (!r.ok && /wiki changed/i.test(r.error)) setConflict(true); + } + } finally { + setSaving(false); + } + } + + return ( +
+
+ Edit config: r/{subreddit} +
+ + + {valid === false ? 'invalid' : valid === true ? 'valid' : ''} + + {conflict && ( + + )} + + +
+
+ +
+ +
+ +
+ {status} +
+
+ ); +} diff --git a/tests/client/config-workbench.test.tsx b/tests/client/config-workbench.test.tsx new file mode 100644 index 0000000..cffa630 --- /dev/null +++ b/tests/client/config-workbench.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; + +const fetchConfigRawSafe = vi.fn(); +const saveConfigSafe = vi.fn(); +const validateConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + fetchConfigRawSafe: () => fetchConfigRawSafe(), + saveConfigSafe: (...a: unknown[]) => saveConfigSafe(...a), + validateConfigSafe: (...a: unknown[]) => validateConfigSafe(...a), +})); + +import { ConfigWorkbench } from '../../src/client/components/ConfigWorkbench'; + +afterEach(() => cleanup()); + +beforeEach(() => { + vi.clearAllMocks(); + fetchConfigRawSafe.mockResolvedValue({ ok: true, empty: false, data: { content: 'runs: []', revisionId: 'rev-1', isDefaultTemplate: false } }); + validateConfigSafe.mockResolvedValue({ ok: true }); + saveConfigSafe.mockResolvedValue({ ok: true, empty: false, data: { rev: 1, ruleCount: 0 } }); +}); + +describe('ConfigWorkbench', () => { + it('loads config then saves', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(saveConfigSafe).toHaveBeenCalled()); + }); + + it('shows subreddit in header', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + expect(screen.getByText(/r\/testsub/i)).toBeTruthy(); + }); + + it('calls onClose when Close button clicked', async () => { + const onClose = vi.fn(); + render(); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const closeBtn = await screen.findByRole('button', { name: /close editor/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalled(); + }); + + it('refreshes baseRev after save (calls fetchConfigRawSafe twice)', async () => { + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalledTimes(1)); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalledTimes(2)); + }); + + it('shows load error if fetchConfigRawSafe fails', async () => { + fetchConfigRawSafe.mockResolvedValue({ ok: false, error: 'Network error' }); + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + expect(await screen.findByText(/load failed/i)).toBeTruthy(); + }); + + it('shows conflict reload button on wiki-changed error', async () => { + saveConfigSafe.mockResolvedValue({ ok: false, error: 'wiki changed beneath you' }); + render( {}} />); + await waitFor(() => expect(fetchConfigRawSafe).toHaveBeenCalled()); + const save = await screen.findByRole('button', { name: /save/i }); + fireEvent.click(save); + await waitFor(() => expect(screen.getByRole('button', { name: /reload/i })).toBeTruthy()); + }); +}); From 77e7bdcd70b39c4f4501436f3b5536b38228e768 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 20:36:06 -0400 Subject: [PATCH 15/21] feat(schema): add property descriptions for editor hover docs Co-Authored-By: Claude Opus 4.7 (1M context) --- src/schema/app.schema.json | 595 ++++++++++++++++++++++++------ tests/schema/descriptions.test.ts | 10 + 2 files changed, 494 insertions(+), 111 deletions(-) create mode 100644 tests/schema/descriptions.test.ts diff --git a/src/schema/app.schema.json b/src/schema/app.schema.json index 5a09820..7db94b8 100644 --- a/src/schema/app.schema.json +++ b/src/schema/app.schema.json @@ -6,15 +6,23 @@ "additionalProperties": false, "required": ["runs"], "properties": { - "dryRun": { "type": "boolean" }, - "needsAuthorEnrichment": { "type": "boolean" }, + "dryRun": { + "type": "boolean", + "description": "Global dry-run flag: when true, all actions are simulated and logged but never executed." + }, + "needsAuthorEnrichment": { + "type": "boolean", + "description": "Set at parse time by inspecting rule kinds. Gates the expensive getUserByUsername call." + }, "namedRules": { "type": "object", - "additionalProperties": { "$ref": "#/definitions/Rule" } + "additionalProperties": { "$ref": "#/definitions/Rule" }, + "description": "Named reusable rules that checks can reference via a NamedRuleRef." }, "runs": { "type": "array", - "items": { "$ref": "#/definitions/Run" } + "items": { "$ref": "#/definitions/Run" }, + "description": "Ordered list of runs to evaluate against each incoming post or comment." } }, "definitions": { @@ -23,10 +31,14 @@ "additionalProperties": false, "required": ["name", "checks"], "properties": { - "name": { "type": "string" }, + "name": { + "type": "string", + "description": "Unique label for this run, used in logs and goto references." + }, "checks": { "type": "array", - "items": { "$ref": "#/definitions/Check" } + "items": { "$ref": "#/definitions/Check" }, + "description": "Ordered list of checks evaluated in sequence within this run." } } }, @@ -35,16 +47,27 @@ "additionalProperties": false, "required": ["name", "combinator", "rules"], "properties": { - "name": { "type": "string" }, - "combinator": { "enum": ["AND", "OR", "NOT"] }, + "name": { + "type": "string", + "description": "Unique label for this check, used in logs and postBehavior goto references." + }, + "combinator": { + "enum": ["AND", "OR", "NOT"], + "description": "How rules are combined: AND requires all rules to trigger, OR requires any, NOT requires none." + }, "rules": { "type": "array", - "items": { "$ref": "#/definitions/Rule" } + "items": { "$ref": "#/definitions/Rule" }, + "description": "Rules evaluated together using the check's combinator." + }, + "filters": { + "$ref": "#/definitions/FilterSpec", + "description": "Optional author and item predicates that gate whether this check runs at all." }, - "filters": { "$ref": "#/definitions/FilterSpec" }, "actions": { "type": "array", - "items": { "$ref": "#/definitions/Action" } + "items": { "$ref": "#/definitions/Action" }, + "description": "Actions executed in order when this check triggers." }, "postBehavior": { "oneOf": [ @@ -55,7 +78,8 @@ "required": ["goto"], "properties": { "goto": { "type": "string" } } } - ] + ], + "description": "What to do after this check: 'next' continues to the next check, 'stop' halts the run, or {goto} jumps to a named check." } } }, @@ -76,35 +100,72 @@ "type": "object", "additionalProperties": false, "required": ["kind", "pattern"], + "description": "Matches a post's title, body, or URL against a regular expression.", "properties": { - "kind": { "const": "regex" }, - "name": { "type": "string" }, - "pattern": { "type": "string" }, - "flags": { "type": "string" }, - "target": { "enum": ["title", "body", "url"] } + "kind": { + "const": "regex", + "description": "Identifies this rule as a regex match rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "pattern": { + "type": "string", + "description": "Regular expression source string to match against the target field." + }, + "flags": { + "type": "string", + "description": "Optional regex flags such as 'i' for case-insensitive or 'm' for multiline." + }, + "target": { + "enum": ["title", "body", "url"], + "description": "Which field to match against: title, body, or url. Defaults to title." + } } }, "AuthorRule": { "type": "object", "additionalProperties": false, "required": ["kind", "filter"], + "description": "Triggers based on properties of the post or comment author.", "properties": { - "kind": { "const": "author" }, - "name": { "type": "string" }, - "filter": { "$ref": "#/definitions/AuthorFilter" } + "kind": { + "const": "author", + "description": "Identifies this rule as an author attribute rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "filter": { + "$ref": "#/definitions/AuthorFilter", + "description": "Author attribute predicates that must be satisfied for this rule to trigger." + } } }, "RuleSetRule": { "type": "object", "additionalProperties": false, "required": ["kind", "combinator", "rules"], + "description": "Combines nested rules with AND, OR, or NOT logic.", "properties": { - "kind": { "const": "ruleset" }, - "name": { "type": "string" }, - "combinator": { "enum": ["AND", "OR", "NOT"] }, + "kind": { + "const": "ruleset", + "description": "Identifies this rule as a nested rule-set combinator." + }, + "name": { + "type": "string", + "description": "Optional label for this rule set, used in logs." + }, + "combinator": { + "enum": ["AND", "OR", "NOT"], + "description": "AND requires all nested rules to trigger, OR requires any, NOT requires none." + }, "rules": { "type": "array", - "items": { "$ref": "#/definitions/Rule" } + "items": { "$ref": "#/definitions/Rule" }, + "description": "Nested rules evaluated together using the combinator." } } }, @@ -112,121 +173,347 @@ "type": "object", "additionalProperties": false, "required": ["kind", "name"], + "description": "Reference to a reusable rule defined in the top-level namedRules map.", "properties": { - "kind": { "const": "named" }, - "name": { "type": "string" } + "kind": { + "const": "named", + "description": "Identifies this as a reference to a named rule." + }, + "name": { + "type": "string", + "description": "Key into the top-level namedRules map to look up." + } } }, "RepostRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers when the same URL has already been submitted within the configured window.", "properties": { - "kind": { "const": "repost" }, - "name": { "type": "string" }, - "windowDays": { "type": "number", "minimum": 1, "maximum": 365 } + "kind": { + "const": "repost", + "description": "Identifies this rule as a URL-deduplication repost rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "windowDays": { + "type": "number", + "minimum": 1, + "maximum": 365, + "description": "How many days back to check for duplicate URLs. Defaults to 30." + } } }, "HistoryRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers based on an author's recent post/comment counts or karma thresholds. Any supplied threshold triggers the rule (OR semantics).", "properties": { - "kind": { "const": "history" }, - "name": { "type": "string" }, - "postCountLt": { "type": "number", "minimum": 0, "maximum": 100 }, - "postCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountLt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "linkKarmaLt": { "type": "number" }, - "linkKarmaGt": { "type": "number" }, - "commentKarmaLt": { "type": "number" }, - "commentKarmaGt": { "type": "number" }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "history", + "description": "Identifies this rule as an author history rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "postCountLt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count is less than this value." + }, + "postCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count is greater than this value." + }, + "commentCountLt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count is less than this value." + }, + "commentCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count is greater than this value." + }, + "linkKarmaLt": { + "type": "number", + "description": "Triggers when the author's link karma is less than this value." + }, + "linkKarmaGt": { + "type": "number", + "description": "Triggers when the author's link karma is greater than this value." + }, + "commentKarmaLt": { + "type": "number", + "description": "Triggers when the author's comment karma is less than this value." + }, + "commentKarmaGt": { + "type": "number", + "description": "Triggers when the author's comment karma is greater than this value." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count posts and comments within the last N seconds. Defaults to unlimited." + } } }, "AttributionRule": { "type": "object", "additionalProperties": false, "required": ["kind", "domains", "domainPercent"], + "description": "Triggers when a configured fraction of the author's recent posts link to specific domains.", "properties": { - "kind": { "const": "attribution" }, - "name": { "type": "string" }, - "domains": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }, - "domainPercent": { "type": "number", "minimum": 0, "maximum": 100 }, - "minPosts": { "type": "number", "minimum": 1 }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "attribution", + "description": "Identifies this rule as a domain attribution rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "domains": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "List of domain substrings to match against each post's domain (case-insensitive)." + }, + "domainPercent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the matching domain percentage of the author's recent posts is at or above this value (0 to 100)." + }, + "minPosts": { + "type": "number", + "minimum": 1, + "description": "Minimum number of recent posts required before evaluating the rule. Defaults to 5." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count posts within the last N seconds. Defaults to unlimited." + } } }, "RecentActivityRule": { "type": "object", "additionalProperties": false, "required": ["kind", "subreddits"], + "description": "Triggers when the author has posted or commented in specified subreddits above configured thresholds.", "properties": { - "kind": { "const": "recentActivity" }, - "name": { "type": "string" }, - "subreddits": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1 }, - "postCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "commentCountGt": { "type": "number", "minimum": 0, "maximum": 100 }, - "windowSec": { "type": "number", "minimum": 1 } + "kind": { + "const": "recentActivity", + "description": "Identifies this rule as a recent subreddit activity rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "subreddits": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "List of subreddit names to check the author's activity in (case-insensitive)." + }, + "postCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent post count in the target subreddits exceeds this value." + }, + "commentCountGt": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Triggers when the author's recent comment count in the target subreddits exceeds this value." + }, + "windowSec": { + "type": "number", + "minimum": 1, + "description": "Only count activity within the last N seconds. Defaults to unlimited." + } } }, "ImageRepostRule": { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Triggers when an image post's perceptual hash is too similar to a recently seen image in the same subreddit.", "properties": { - "kind": { "const": "imageRepost" }, - "name": { "type": "string" }, - "hammingThreshold": { "type": "number", "minimum": 0, "maximum": 256 }, - "windowDays": { "type": "number", "minimum": 1, "maximum": 365 } + "kind": { + "const": "imageRepost", + "description": "Identifies this rule as a perceptual image-repost detection rule." + }, + "name": { + "type": "string", + "description": "Optional label for this rule, used in logs." + }, + "hammingThreshold": { + "type": "number", + "minimum": 0, + "maximum": 256, + "description": "Maximum bit difference between two image hashes to consider them the same image. Defaults to 8." + }, + "windowDays": { + "type": "number", + "minimum": 1, + "maximum": 365, + "description": "How many days back to check for matching image hashes. Defaults to 30." + } } }, "FilterSpec": { "type": "object", "additionalProperties": false, + "description": "Optional gate predicates applied before a check's rules run.", "properties": { - "authorIs": { "$ref": "#/definitions/AuthorFilter" }, - "itemIs": { "$ref": "#/definitions/ItemFilter" } + "authorIs": { + "$ref": "#/definitions/AuthorFilter", + "description": "Author predicates that must all match for the check to proceed." + }, + "itemIs": { + "$ref": "#/definitions/ItemFilter", + "description": "Item predicates that must all match for the check to proceed." + } } }, "AuthorFilter": { "type": "object", "additionalProperties": false, + "description": "Predicate set evaluated against the post or comment author.", "properties": { - "nameIn": { "type": "array", "items": { "type": "string" } }, - "nameNotIn": { "type": "array", "items": { "type": "string" } }, - "flairTextIn": { "type": "array", "items": { "type": "string" } }, - "flairTextNotIn": { "type": "array", "items": { "type": "string" } }, - "ageMinSec": { "type": "number" }, - "ageMaxSec": { "type": "number" }, - "linkKarmaMin": { "type": "number" }, - "linkKarmaMax": { "type": "number" }, - "commentKarmaMin": { "type": "number" }, - "commentKarmaMax": { "type": "number" }, - "isMod": { "type": "boolean" }, - "isContributor": { "type": "boolean" }, - "verified": { "type": "boolean" }, - "shadowBanned": { "type": "boolean" } + "nameIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's username is in this exact-match list." + }, + "nameNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's username is NOT in this exact-match list." + }, + "flairTextIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's flair text is one of these values." + }, + "flairTextNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the author's flair text is not one of these values." + }, + "ageMinSec": { + "type": "number", + "description": "Matches when the author's account age is at least this many seconds." + }, + "ageMaxSec": { + "type": "number", + "description": "Matches when the author's account age is at most this many seconds." + }, + "linkKarmaMin": { + "type": "number", + "description": "Matches when the author's link karma is at least this value." + }, + "linkKarmaMax": { + "type": "number", + "description": "Matches when the author's link karma is at most this value." + }, + "commentKarmaMin": { + "type": "number", + "description": "Matches when the author's comment karma is at least this value." + }, + "commentKarmaMax": { + "type": "number", + "description": "Matches when the author's comment karma is at most this value." + }, + "isMod": { + "type": "boolean", + "description": "When true, matches moderators only. When false, matches non-moderators only." + }, + "isContributor": { + "type": "boolean", + "description": "When true, matches approved contributors only. When false, matches non-contributors only." + }, + "verified": { + "type": "boolean", + "description": "When true, matches authors with a verified email only." + }, + "shadowBanned": { + "type": "boolean", + "description": "When true, matches shadow-banned accounts only." + } } }, "ItemFilter": { "type": "object", "additionalProperties": false, + "description": "Predicate set evaluated against the post or comment itself.", "properties": { - "over18": { "type": "boolean" }, - "locked": { "type": "boolean" }, - "stickied": { "type": "boolean" }, - "removed": { "type": "boolean" }, - "approved": { "type": "boolean" }, - "isSelf": { "type": "boolean" }, - "scoreMin": { "type": "number" }, - "scoreMax": { "type": "number" }, - "linkFlairTextIn": { "type": "array", "items": { "type": "string" } }, - "linkFlairTextNotIn": { "type": "array", "items": { "type": "string" } }, - "titleMatches": { "type": "string" }, - "bodyMatches": { "type": "string" }, - "urlMatches": { "type": "string" } + "over18": { + "type": "boolean", + "description": "When true, matches NSFW items only. When false, matches SFW items only." + }, + "locked": { + "type": "boolean", + "description": "When true, matches locked items only. When false, matches unlocked items only." + }, + "stickied": { + "type": "boolean", + "description": "When true, matches stickied items only. When false, matches non-stickied items only." + }, + "removed": { + "type": "boolean", + "description": "When true, matches removed items only. When false, matches non-removed items only." + }, + "approved": { + "type": "boolean", + "description": "When true, matches approved items only. When false, matches non-approved items only." + }, + "isSelf": { + "type": "boolean", + "description": "When true, matches self-text posts only. When false, matches link posts only." + }, + "scoreMin": { + "type": "number", + "description": "Matches when the item's score is at least this value." + }, + "scoreMax": { + "type": "number", + "description": "Matches when the item's score is at most this value." + }, + "linkFlairTextIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the post's link flair text is one of these values." + }, + "linkFlairTextNotIn": { + "type": "array", + "items": { "type": "string" }, + "description": "Matches when the post's link flair text is not one of these values." + }, + "titleMatches": { + "type": "string", + "description": "Regex source string matched against the post title." + }, + "bodyMatches": { + "type": "string", + "description": "Regex source string matched against the post or comment body." + }, + "urlMatches": { + "type": "string", + "description": "Regex source string matched against the post URL." + } } }, "Action": { @@ -235,82 +522,168 @@ "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Removes the post or comment from the subreddit.", "properties": { - "kind": { "const": "remove" }, - "isSpam": { "type": "boolean" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "remove", + "description": "Identifies this action as a removal action." + }, + "isSpam": { + "type": "boolean", + "description": "When true, marks the removal as a spam removal." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Approves the post or comment.", "properties": { - "kind": { "const": "approve" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "approve", + "description": "Identifies this action as an approval action." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind", "template"], + "description": "Posts a moderator comment using the provided template text.", "properties": { - "kind": { "const": "comment" }, - "template": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "comment", + "description": "Identifies this action as a comment action." + }, + "template": { + "type": "string", + "description": "Text of the comment to post. Supports template variables." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Locks the post or comment to prevent further replies.", "properties": { - "kind": { "const": "lock" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "lock", + "description": "Identifies this action as a lock action." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind", "reason"], + "description": "Reports the post or comment to the moderators with a reason.", "properties": { - "kind": { "const": "report" }, - "reason": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "report", + "description": "Identifies this action as a report action." + }, + "reason": { + "type": "string", + "description": "The report reason shown to moderators." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Bans the author from the subreddit, optionally with a duration and message.", "properties": { - "kind": { "const": "ban" }, - "duration": { "type": "number" }, - "reason": { "type": "string" }, - "note": { "type": "string" }, - "message": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "ban", + "description": "Identifies this action as a ban action." + }, + "duration": { + "type": "number", + "description": "Ban duration in days. Use 0 for a permanent ban." + }, + "reason": { + "type": "string", + "description": "The mod-visible reason for the ban." + }, + "note": { + "type": "string", + "description": "Internal mod note attached to the ban, not shown to the user." + }, + "message": { + "type": "string", + "description": "Message sent to the banned user explaining the ban." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Sets the author's user flair on the subreddit.", "properties": { - "kind": { "const": "userFlair" }, - "text": { "type": "string" }, - "cssClass": { "type": "string" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "userFlair", + "description": "Identifies this action as a user flair assignment action." + }, + "text": { + "type": "string", + "description": "Flair text to apply to the author." + }, + "cssClass": { + "type": "string", + "description": "CSS class for the flair styling." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } }, { "type": "object", "additionalProperties": false, "required": ["kind"], + "description": "Marks the post or comment as moderator-distinguished, optionally stickying it.", "properties": { - "kind": { "const": "distinguish" }, - "sticky": { "type": "boolean" }, - "dryRun": { "type": "boolean" } + "kind": { + "const": "distinguish", + "description": "Identifies this action as a mod-distinguish action." + }, + "sticky": { + "type": "boolean", + "description": "When true, pins the distinguished comment to the top of the thread (post-level comments only). Defaults to true." + }, + "dryRun": { + "type": "boolean", + "description": "When true, simulates the action without executing it." + } } } ] diff --git a/tests/schema/descriptions.test.ts b/tests/schema/descriptions.test.ts new file mode 100644 index 0000000..36fcfb6 --- /dev/null +++ b/tests/schema/descriptions.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import schema from '../../src/schema/app.schema.json'; + +describe('schema descriptions', () => { + it('documents the top-level runs property', () => { + const runs = (schema as { properties?: { runs?: { description?: string } } }).properties?.runs; + expect(typeof runs?.description).toBe('string'); + expect((runs?.description ?? '').length).toBeGreaterThan(0); + }); +}); From 1569328b8914f03d599f4e83d2c048aa92c1b705 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 20:43:06 -0400 Subject: [PATCH 16/21] feat(client): PreviewPane with Impact/Explain/Diff tabs (Task 13) Adds PreviewPane component with three tabs: - Impact: debounced (700ms) simulateLiveSafe showing fire-rate vs recent items - Explain: on-demand explainConfigSafe with AI explanation - Diff: reuses exported simpleDiff from ConfigDiffViewer to show line-level changes between the loaded baseline and current edited text simpleDiff was already exported from ConfigDiffViewer.tsx (line 50). No duplication of LCS logic needed. "No changes." shows when no add/del entries exist (same-only diff = no unsaved edits). Tests (preview-pane.test.tsx): 7 tests covering tab rendering, aria-selected state, Explain button flow, loading placeholder, Diff changed-line detection, Diff no-changes state, Impact error path. All 198 client tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/components/PreviewPane.tsx | 121 ++++++++++++++++++++++++ tests/client/preview-pane.test.tsx | 127 ++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/client/components/PreviewPane.tsx create mode 100644 tests/client/preview-pane.test.tsx diff --git a/src/client/components/PreviewPane.tsx b/src/client/components/PreviewPane.tsx new file mode 100644 index 0000000..4dc63a3 --- /dev/null +++ b/src/client/components/PreviewPane.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react'; +import { simulateLiveSafe, explainConfigSafe } from '../lib/api'; +import { simpleDiff } from './ConfigDiffViewer'; + +type Tab = 'impact' | 'explain' | 'diff'; + +export function PreviewPane({ text, currentText }: { text: string; currentText: string }) { + const [tab, setTab] = useState('impact'); + const [impact, setImpact] = useState('Edit to preview impact.'); + const [explanation, setExplanation] = useState(''); + const debounce = useRef | null>(null); + + useEffect(() => { + if (tab !== 'impact') return; + if (debounce.current) clearTimeout(debounce.current); + debounce.current = setTimeout(async () => { + const r = await simulateLiveSafe(text); + setImpact( + r.ok && !r.empty + ? `Would fire on ${r.data.firedCount}/${r.data.totalSamples} recent items` + : r.ok + ? 'No result.' + : r.error + ); + }, 700); + return () => { + if (debounce.current) clearTimeout(debounce.current); + }; + }, [text, tab]); + + async function runExplain() { + setExplanation('...'); + const r = await explainConfigSafe(text); + setExplanation( + r.ok && !r.empty + ? r.data + : `Explain failed: ${r.ok ? 'empty' : r.error}` + ); + } + + const diffLines = simpleDiff(currentText, text); + + return ( +
+
+ + + +
+ +
+ {tab === 'impact' &&

{impact}

} + + {tab === 'explain' && ( +
+ +
{explanation}
+
+ )} + + {tab === 'diff' && ( +
+            {!diffLines.some((d) => d.tag === 'add' || d.tag === 'del') ? (
+              No changes.
+            ) : (
+              diffLines.map((d, i) => (
+                
+ {d.tag === 'add' + ? '+' + : d.tag === 'del' + ? '-' + : d.tag === 'too-large' + ? '!' + : ' '}{' '} + {d.line} +
+ )) + )} +
+ )} +
+
+ ); +} diff --git a/tests/client/preview-pane.test.tsx b/tests/client/preview-pane.test.tsx new file mode 100644 index 0000000..eed277b --- /dev/null +++ b/tests/client/preview-pane.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; + +// Mock the API helpers BEFORE importing PreviewPane so the module-level +// mock is in place when PreviewPane's module is first evaluated. +const simulateLiveSafe = vi.fn(); +const explainConfigSafe = vi.fn(); +vi.mock('../../src/client/lib/api', () => ({ + simulateLiveSafe: (...a: unknown[]) => simulateLiveSafe(...a), + explainConfigSafe: (...a: unknown[]) => explainConfigSafe(...a), +})); + +import { PreviewPane } from '../../src/client/components/PreviewPane'; + +afterEach(() => cleanup()); + +beforeEach(() => { + vi.clearAllMocks(); + simulateLiveSafe.mockResolvedValue({ + ok: true, + empty: false, + data: { totalSamples: 10, firedCount: 3, erroredCount: 0 }, + }); + explainConfigSafe.mockResolvedValue({ + ok: true, + empty: false, + data: 'This config matches posts with runs: [a].', + }); +}); + +describe('PreviewPane', () => { + it('renders all three tabs', () => { + render(); + const tabs = screen.getAllByRole('tab'); + expect(tabs.length).toBe(3); + const labels = tabs.map((t) => t.textContent?.trim()); + expect(labels).toContain('Impact'); + expect(labels).toContain('Explain'); + expect(labels).toContain('Diff'); + }); + + it('defaults to the Impact tab selected', () => { + render(); + const impactTab = screen.getByRole('tab', { name: /impact/i }); + expect(impactTab.getAttribute('aria-selected')).toBe('true'); + }); + + it('Explain tab: clicking "Explain with AI" shows AI explanation', async () => { + render(); + + // Switch to the Explain tab + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + + // The Explain tab should now be visible with the button + const explainBtn = await screen.findByRole('button', { name: /explain with ai/i }); + fireEvent.click(explainBtn); + + // Wait for the async explain call to resolve and the text to appear + await waitFor(() => + expect(screen.getByText(/This config matches posts with runs/i)).toBeTruthy() + ); + expect(explainConfigSafe).toHaveBeenCalledWith('runs: [a]'); + }); + + it('Explain tab: shows loading placeholder "..." while waiting', async () => { + // Use a never-resolving promise so we can observe the interim state + explainConfigSafe.mockImplementation(() => new Promise(() => {})); + + render(); + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + + const explainBtn = await screen.findByRole('button', { name: /explain with ai/i }); + fireEvent.click(explainBtn); + + // "..." should appear immediately as the loading indicator + expect(screen.getByText('...')).toBeTruthy(); + }); + + it('Diff tab: shows changed lines when text differs from currentText', async () => { + render(); + + // Switch to the Diff tab + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + + // The diff should show the old line removed and new line added + // simpleDiff('runs: []', 'runs: [a]') produces del + add entries + await waitFor(() => { + // Look for deletion and addition markers in the rendered diff + const pre = document.querySelector('pre'); + expect(pre).toBeTruthy(); + const content = pre?.textContent ?? ''; + // Should contain both - and + markers for the changed line + expect(content).toContain('-'); + expect(content).toContain('+'); + }); + }); + + it('Diff tab: shows "No changes." when text matches currentText', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: /diff/i })); + expect(screen.getByText('No changes.')).toBeTruthy(); + }); + + it('Impact tab: shows error message when simulateLiveSafe returns ok:false', async () => { + simulateLiveSafe.mockResolvedValue({ ok: false, error: 'simulation failed' }); + render(); + + await waitFor( + () => { + expect(screen.getByText('simulation failed')).toBeTruthy(); + }, + { timeout: 2000 } + ); + }); + + it('switching tabs away from Impact and back does not leave stale debounce running', async () => { + render(); + + // Start on Impact, switch away, switch back + fireEvent.click(screen.getByRole('tab', { name: /explain/i })); + fireEvent.click(screen.getByRole('tab', { name: /impact/i })); + + // After switching back, the component should still render without errors + expect(screen.getByRole('tab', { name: /impact/i }).getAttribute('aria-selected')).toBe('true'); + }); +}); From f9fe369805e8143b86740e24b01ab8a50fc14806 Mon Sep 17 00:00:00 2001 From: Stephen Sookra Date: Wed, 27 May 2026 20:43:19 -0400 Subject: [PATCH 17/21] feat(client): wire PreviewPane into ConfigWorkbench split layout (Task 14) - Adds loadedText state: set in load() on initial fetch and after save+reload, NOT on user edits. This gives PreviewPane's Diff tab a stable baseline (currentText) that resets to '' post-save, so the Diff is empty when the editor reflects the just-saved content. - Editor now fills flex-1 on the left; PreviewPane takes flex-[0_0_40%] on the right, with text={text} currentText={loadedText} props. - config-workbench.test.tsx mock extended with simulateLiveSafe and explainConfigSafe stubs so the test suite continues to pass with the PreviewPane rendered inside the workbench. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/client/components/ConfigWorkbench.tsx | 17 +++++++++++++++-- tests/client/config-workbench.test.tsx | 8 ++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/client/components/ConfigWorkbench.tsx b/src/client/components/ConfigWorkbench.tsx index 4d12e0c..940c7ff 100644 --- a/src/client/components/ConfigWorkbench.tsx +++ b/src/client/components/ConfigWorkbench.tsx @@ -13,6 +13,7 @@ */ import { useEffect, useRef, useState } from 'react'; import { ConfigEditor } from './ConfigEditor'; +import { PreviewPane } from './PreviewPane'; import { fetchConfigRawSafe, validateConfigSafe, saveConfigSafe } from '../lib/api'; function detectFormat(text: string): 'yaml' | 'json' { @@ -22,6 +23,12 @@ function detectFormat(text: string): 'yaml' | 'json' { export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onClose: () => void }) { const [text, setText] = useState(''); + // loadedText tracks the last-fetched wiki content (Diff baseline). + // Updated in load() on initial fetch and after a successful save+reload. + // NOT updated by onChange, so the Diff tab always shows unsaved edits + // vs the last server state. Cleared to '' right after a save+reload so + // the Diff tab is empty when the editor reflects the just-saved content. + const [loadedText, setLoadedText] = useState(''); const [format, setFormat] = useState<'yaml' | 'json'>('yaml'); const [baseRev, setBaseRev] = useState(null); const [valid, setValid] = useState(null); @@ -39,6 +46,7 @@ export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onC const r = await fetchConfigRawSafe(); if (r.ok && !r.empty) { setText(r.data.content); + setLoadedText(r.data.content); setFormat(detectFormat(r.data.content)); setBaseRev(r.data.revisionId); setConflict(false); @@ -123,8 +131,13 @@ export function ConfigWorkbench({ subreddit, onClose }: { subreddit: string; onC
-
- +
+
+ +
+
+ +