From 572d22bfbe82602080e457bec655f72e3314f9ef Mon Sep 17 00:00:00 2001 From: Yuc Date: Thu, 23 Jul 2026 04:19:16 +0800 Subject: [PATCH 01/87] fix(installer): Codex TOML block finder preserves trailing array-of-tables siblings (#1351) (#1370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex installer's `findNextTableHeader` skipped `[[array-of-tables]]` headers instead of treating them as a block boundary, so any `[[...]]` block after `[mcp_servers.codegraph]` in ~/.codex/config.toml was silently deleted on install/upgrade/uninstall. Now treats both `[...]` and `[[...]]` as boundaries, with a small line lexer so header-shaped text inside multiline strings/arrays isn't mistaken for a boundary. Adds round-trip regression coverage (install → reinstall → uninstall) + CHANGELOG entry. Fixes #1351. Supersedes #624. Thanks @KtzeAbyss. --- CHANGELOG.md | 1 + __tests__/installer-targets.test.ts | 149 ++++++++++++++++++++++++++++ src/installer/targets/toml.ts | 138 ++++++++++++++++++++++---- 3 files changed, 269 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aa5bf23..d2ae567d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Full details in the entries below. ### Fixes +- Codex installs, upgrades, and uninstalls now preserve TOML array-of-table sections that appear after CodeGraph's MCP configuration instead of accidentally removing them. (#1351) - TypeScript, TSX, and JavaScript files now parse with up-to-date grammars — modern syntax such as `using` declarations and import attributes no longer trips parse errors that could drop surrounding symbols. (The previously bundled grammars dated from 2023.) - Rust files also parse with an up-to-date grammar now (the previously bundled build dated from 2023), which additionally sharpens method-call attribution: calls through struct fields resolve with receiver context instead of falling back to ambiguous bare-name matching, removing a class of wrong call edges on common names like `len` and `start`. - Ruby files also parse with an up-to-date grammar now (the previously bundled build dated from early 2024), which fixes a misparse of safe-navigation operator-method calls (`recv&.!= x`) that had recorded the wrong callee name. diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e11efd3cc..6db793d65 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -876,6 +876,48 @@ describe('Installer targets — partial-state idempotency', () => { expect(after).not.toContain('enabled = true'); }); + it('codex: install, re-install, and uninstall preserve trailing array-of-tables siblings', () => { + const codex = getTarget('codex')!; + const tomlPath = path.join(tmpHome, '.codex', 'config.toml'); + fs.mkdirSync(path.dirname(tomlPath), { recursive: true }); + const historyTables = [ + '[[history]]', + 'id = 1', + 'note = "keep first"', + '', + '[[history]]', + 'id = 2', + 'note = "keep second"', + '', + ].join('\n'); + fs.writeFileSync(tomlPath, [ + '[mcp_servers.codegraph]', + 'command = "old-codegraph"', + 'args = ["old"]', + 'description = """', + 'header-shaped text inside a multiline string:', + '[[not-a-table]]', + 'still part of the string', + '"""', + '', + historyTables, + ].join('\n')); + + const first = codex.install('global', { autoAllow: false }); + expect(first.files.find((f) => f.path === tomlPath)?.action).toBe('updated'); + const afterInstall = fs.readFileSync(tomlPath, 'utf-8'); + expect(afterInstall).toContain('command = "codegraph"'); + expect(afterInstall).not.toContain('[[not-a-table]]'); + expect(afterInstall.endsWith(historyTables)).toBe(true); + + const second = codex.install('global', { autoAllow: false }); + expect(second.files.find((f) => f.path === tomlPath)?.action).toBe('unchanged'); + expect(fs.readFileSync(tomlPath, 'utf-8')).toBe(afterInstall); + + codex.uninstall('global'); + expect(fs.readFileSync(tomlPath, 'utf-8')).toBe(historyTables); + }); + it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => { const claude = getTarget('claude')!; const result = claude.install('local', { autoAllow: false }); @@ -1292,6 +1334,113 @@ describe('Installer targets — TOML serializer (Codex backbone)', () => { expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2); expect(content).toContain('[mcp_servers.codegraph]'); }); + + it('upsert replaces the managed table without consuming trailing array-of-tables siblings', () => { + const historyTables = [ + '[[history]]', + 'id = 1', + 'note = "keep first"', + '', + '[[history]]', + 'id = 2', + 'note = "keep second"', + '', + ].join('\n'); + const existing = [ + '[mcp_servers.codegraph]', + 'command = "old-codegraph"', + 'args = ["old"]', + '', + historyTables, + ].join('\n'); + const block = buildTomlTable('mcp_servers.codegraph', { + command: 'codegraph', + args: ['serve', '--mcp'], + }); + + const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', block); + + expect(action).toBe('replaced'); + expect(content).toBe(`${block}\n\n${historyTables}`); + }); + + it('remove preserves trailing array-of-tables siblings byte-for-byte', () => { + const historyTables = [ + '[[history]]', + 'id = 1', + 'note = "keep first"', + '', + '[[history]]', + 'id = 2', + 'note = "keep second"', + '', + ].join('\n'); + const existing = [ + '[mcp_servers.codegraph]', + 'command = "codegraph"', + 'args = ["serve", "--mcp"]', + '', + historyTables, + ].join('\n'); + + const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph'); + + expect(action).toBe('removed'); + expect(content).toBe(historyTables); + }); + + it.each([ + ['table', '[ mcp_servers.other ]'], + ['array-of-tables', '[[ history ]]'], + ])('preserves a trailing %s header with inner whitespace', (_kind, siblingHeader) => { + const siblingTable = `${siblingHeader}\nvalue = "keep"\n`; + const existing = [ + '[mcp_servers.codegraph]', + 'command = "old-codegraph"', + 'args = ["old"]', + '', + siblingTable, + ].join('\n'); + const block = buildTomlTable('mcp_servers.codegraph', { + command: 'codegraph', + args: ['serve', '--mcp'], + }); + + const upserted = upsertTomlTable(existing, 'mcp_servers.codegraph', block); + const removed = removeTomlTable(existing, 'mcp_servers.codegraph'); + + expect(upserted.content).toBe(`${block}\n\n${siblingTable}`); + expect(removed.content).toBe(siblingTable); + }); + + it.each([ + ['basic', '"""'], + ['literal', "'''"], + ])('ignores header-shaped text inside a multiline %s string', (_kind, delimiter) => { + const historyTable = '[[history]]\nid = 1\n'; + const existing = [ + '[mcp_servers.codegraph]', + 'command = "old-codegraph"', + 'args = [', + ` ${delimiter}first line`, + '[[not-a-table]]', + `last line${delimiter},`, + ' "serve",', + ']', + '', + historyTable, + ].join('\n'); + const block = buildTomlTable('mcp_servers.codegraph', { + command: 'codegraph', + args: ['serve', '--mcp'], + }); + + const upserted = upsertTomlTable(existing, 'mcp_servers.codegraph', block); + const removed = removeTomlTable(existing, 'mcp_servers.codegraph'); + + expect(upserted.content).toBe(`${block}\n\n${historyTable}`); + expect(removed.content).toBe(historyTable); + }); }); describe('Installer — uninstallTargets sweep (codegraph uninstall)', () => { diff --git a/src/installer/targets/toml.ts b/src/installer/targets/toml.ts index 29348a7c9..1dc086bf3 100644 --- a/src/installer/targets/toml.ts +++ b/src/installer/targets/toml.ts @@ -7,13 +7,14 @@ * * Strategy: treat the file as text. Find the `[mcp_servers.codegraph]` * header line, splice it (and the lines that follow it until the next - * `[...]` header or EOF) in or out. Everything outside that block is - * preserved verbatim, byte-for-byte. + * `[...]` / `[[...]]` header or EOF) in or out. A small lexical scan keeps + * header-shaped text inside multiline values out of the boundary search. + * Everything outside that block is preserved verbatim, byte-for-byte. * * Limitations (acceptable for our narrow use): - * - Only handles top-level table headers; not array-of-tables or - * subtables nested inside `[mcp_servers]` itself (we always write - * the full dotted key `[mcp_servers.codegraph]`). + * - Only writes a top-level table header. Array-of-tables and sibling + * subtables are preserved as opaque blocks (we always write the full + * dotted key `[mcp_servers.codegraph]`). * - Doesn't validate sibling TOML — if the file is malformed * elsewhere, our injection won't fix it but won't make it worse. * - Quotes string values with double quotes; escapes `\` and `"`. @@ -79,7 +80,7 @@ export function upsertTomlTable( }; } - // Find the end of this block: next `[...]` header (at line start) or EOF. + // Find the end of this block: next table header or EOF. const blockEnd = findNextTableHeader(fileContent, headerIdx + headerLine.length); const existingBlock = fileContent.substring(headerIdx, blockEnd).replace(/\n+$/, ''); @@ -133,22 +134,121 @@ function findHeaderIndex(content: string, headerLine: string): number { } /** - * Find the byte index of the next top-level `[...]` table header - * (excluding array-of-tables `[[...]]`) starting from `from`, or - * return content length when none. + * Find the byte index of the next `[...]` or `[[...]]` table header + * starting from `from`, or return content length when none. */ function findNextTableHeader(content: string, from: number): number { - // Look for "\n[" but skip "\n[[" (array of tables). - let i = from; - while (i < content.length) { - const nlIdx = content.indexOf('\n[', i); - if (nlIdx === -1) return content.length; - if (content[nlIdx + 2] === '[') { - // [[...]] — keep searching past it. - i = nlIdx + 2; - continue; + const state: TomlLexState = { multilineString: null, arrayDepth: 0, inlineTableDepth: 0 }; + let lineStart = from; + let isHeaderRemainder = true; + + while (lineStart < content.length) { + const newlineIdx = content.indexOf('\n', lineStart); + const lineEnd = newlineIdx === -1 ? content.length : newlineIdx; + const line = content.slice(lineStart, lineEnd); + + if ( + !isHeaderRemainder && + state.multilineString === null && + state.arrayDepth === 0 && + state.inlineTableDepth === 0 && + isTomlTableHeader(line) + ) { + return lineStart; } - return nlIdx + 1; + + scanTomlLine(line, state); + if (newlineIdx === -1) break; + lineStart = newlineIdx + 1; + isHeaderRemainder = false; } + return content.length; } + +type MultilineStringDelimiter = '"""' | "'''"; + +interface TomlLexState { + multilineString: MultilineStringDelimiter | null; + arrayDepth: number; + inlineTableDepth: number; +} + +const TOML_KEY_PART = String.raw`(?:[A-Za-z0-9_-]+|"(?:\\.|[^"\\])*"|'[^']*')`; +const TOML_DOTTED_KEY = String.raw`${TOML_KEY_PART}(?:[ \t]*\.[ \t]*${TOML_KEY_PART})*`; +const TOML_TABLE = String.raw`\[[ \t]*${TOML_DOTTED_KEY}[ \t]*\]`; +const TOML_ARRAY_TABLE = String.raw`\[\[[ \t]*${TOML_DOTTED_KEY}[ \t]*\]\]`; +const TOML_TABLE_HEADER = new RegExp( + String.raw`^[ \t]*(?:${TOML_TABLE}|${TOML_ARRAY_TABLE})[ \t]*(?:#.*)?\r?$` +); + +function isTomlTableHeader(line: string): boolean { + return TOML_TABLE_HEADER.test(line); +} + +/** Track value constructs that may legally span lines so bracket-shaped string + * content and nested arrays cannot be mistaken for sibling table headers. */ +function scanTomlLine(line: string, state: TomlLexState): void { + for (let i = 0; i < line.length;) { + if (state.multilineString !== null) { + const end = findMultilineStringEnd(line, i, state.multilineString); + if (end === -1) return; + i = end + state.multilineString.length; + state.multilineString = null; + continue; + } + + if (line[i] === '#') return; + + const multiline = line.startsWith('"""', i) + ? '"""' + : line.startsWith("'''", i) + ? "'''" + : null; + if (multiline !== null) { + state.multilineString = multiline; + i += multiline.length; + continue; + } + + const ch = line[i]!; + if (ch === '"' || ch === "'") { + i = skipSingleLineString(line, i, ch); + continue; + } + if (ch === '[') state.arrayDepth++; + else if (ch === ']' && state.arrayDepth > 0) state.arrayDepth--; + else if (ch === '{') state.inlineTableDepth++; + else if (ch === '}' && state.inlineTableDepth > 0) state.inlineTableDepth--; + i++; + } +} + +function findMultilineStringEnd( + line: string, + from: number, + delimiter: MultilineStringDelimiter, +): number { + let end = line.indexOf(delimiter, from); + while (delimiter === '"""' && end !== -1 && isBackslashEscaped(line, end)) { + end = line.indexOf(delimiter, end + 1); + } + return end; +} + +function isBackslashEscaped(line: string, index: number): boolean { + let backslashes = 0; + for (let i = index - 1; i >= 0 && line[i] === '\\'; i--) backslashes++; + return backslashes % 2 === 1; +} + +function skipSingleLineString(line: string, start: number, quote: '"' | "'"): number { + for (let i = start + 1; i < line.length; i++) { + if (quote === '"' && line[i] === '\\') { + i++; + continue; + } + if (line[i] === quote) return i + 1; + } + return line.length; +} From 490791c07a13691621d5a8dc84fb16dc9b051de1 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 23 Jul 2026 17:29:38 -0500 Subject: [PATCH 02/87] =?UTF-8?q?feat(installer):=20GitHub=20Copilot=20tar?= =?UTF-8?q?gets=20=E2=80=94=20VS=20Code,=20Copilot=20CLI,=20JetBrains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new installer targets so `codegraph install` can wire the MCP server into GitHub Copilot surfaces: - copilot-vscode: .vscode/mcp.json (local) or the VS Code User-dir mcp.json (global), JSONC-surgical edits, `--path` pinned via ${workspaceFolder} for global installs - copilot-cli: ~/.copilot/mcp-config.json - copilot-jetbrains: github-copilot config dir (XDG / %LOCALAPPDATA%) Detection, install, uninstall, and --print-config are covered for all three in installer-targets.test.ts, including platform-specific path resolution. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 + README.md | 14 +- __tests__/installer-targets.test.ts | 501 +++++++++++++++++++++ src/bin/codegraph.ts | 4 +- src/installer/index.ts | 7 +- src/installer/targets/copilot-cli.ts | 166 +++++++ src/installer/targets/copilot-jetbrains.ts | 230 ++++++++++ src/installer/targets/copilot-vscode.ts | 202 +++++++++ src/installer/targets/registry.ts | 6 + src/installer/targets/types.ts | 2 +- 10 files changed, 1124 insertions(+), 11 deletions(-) create mode 100644 src/installer/targets/copilot-cli.ts create mode 100644 src/installer/targets/copilot-jetbrains.ts create mode 100644 src/installer/targets/copilot-vscode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..1b511516a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- GitHub Copilot is now a supported agent: `codegraph install` can configure Copilot Chat in VS Code (`copilot-vscode`), the GitHub Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`). Installed Copilot surfaces are auto-detected like every other agent, existing MCP server entries in their config files are preserved, and `codegraph uninstall` reverses the setup cleanly. Restart VS Code or your JetBrains IDE after installing so Copilot picks up the server. ## [1.5.0] - 2026-07-21 diff --git a/README.md b/README.md index dccebd3e4..edb4ff3fd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade` Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and GitHub Copilot with Semantic Code Intelligence **The fastest complete code graph · surgical context · built for how agents actually work · 100% local** @@ -35,6 +35,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) +[![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-supported-blueviolet.svg)](#supported-agents)
@@ -104,7 +105,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -371,7 +372,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs) - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -385,7 +386,9 @@ The installer **wires up your agents only — it does not index your code.** Aft codegraph install --yes # auto-detect agents, install global codegraph install --target=cursor,claude --yes # explicit target list codegraph install --target=auto --location=local # detected agents, project-local +codegraph install --target=copilot-vscode,copilot-cli,copilot-jetbrains --yes # GitHub Copilot everywhere codegraph install --print-config codex # print snippet, no file writes +codegraph install --print-config copilot-vscode # same, for Copilot in VS Code ``` | Flag | Values | Default | @@ -398,7 +401,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load. ### 3. Initialize Projects @@ -756,6 +759,7 @@ is written): - **Gemini CLI** - **Antigravity IDE** - **Kiro** +- **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`) ## Supported Languages @@ -854,7 +858,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro** +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 6db793d65..29353547b 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { parse as parseJsonc } from 'jsonc-parser'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; import { uninstallTargets, refreshTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; @@ -38,12 +39,14 @@ function setHome(dir: string): { restore: () => void } { APPDATA: process.env.APPDATA, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, HERMES_HOME: process.env.HERMES_HOME, + COPILOT_HOME: process.env.COPILOT_HOME, }; process.env.HOME = dir; process.env.USERPROFILE = dir; process.env.APPDATA = path.join(dir, '.config'); process.env.XDG_CONFIG_HOME = path.join(dir, '.config'); delete process.env.HERMES_HOME; + delete process.env.COPILOT_HOME; return { restore() { if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME; @@ -51,6 +54,7 @@ function setHome(dir: string): { restore: () => void } { if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA; if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME; if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME; + if (prev.COPILOT_HOME === undefined) delete process.env.COPILOT_HOME; else process.env.COPILOT_HOME = prev.COPILOT_HOME; }, }; } @@ -136,6 +140,12 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } + // VS Code's mcp.json uses `servers`; the JetBrains Copilot + // plugin's mcp.json is schema-compatible with it. + if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') { + delete seed.mcpServers; + seed.servers = { other: { command: 'x' } }; + } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -144,6 +154,9 @@ describe('Installer targets — contract', () => { if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); + } else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') { + expect(after.servers.other).toBeDefined(); + expect(after.servers.codegraph).toBeDefined(); } else { expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined(); @@ -1229,6 +1242,9 @@ describe('Installer targets — registry', () => { expect(getTarget('gemini')?.id).toBe('gemini'); expect(getTarget('antigravity')?.id).toBe('antigravity'); expect(getTarget('kiro')?.id).toBe('kiro'); + expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode'); + expect(getTarget('copilot-cli')?.id).toBe('copilot-cli'); + expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); @@ -1239,6 +1255,18 @@ describe('Installer targets — registry', () => { expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']); }); + it("resolveTargetFlag('all') includes every Copilot target", () => { + const ids = resolveTargetFlag('all', 'global').map((t) => t.id); + expect(ids).toContain('copilot-vscode'); + expect(ids).toContain('copilot-cli'); + expect(ids).toContain('copilot-jetbrains'); + }); + + it('resolveTargetFlag resolves the Copilot ids from a csv list', () => { + const csv = resolveTargetFlag('copilot-vscode,copilot-cli,copilot-jetbrains', 'global'); + expect(csv.map((t) => t.id)).toEqual(['copilot-vscode', 'copilot-cli', 'copilot-jetbrains']); + }); + it('resolveTargetFlag throws on unknown id', () => { expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/); }); @@ -1858,3 +1886,476 @@ describe('Installer targets — opencode XDG config path (#535)', () => { expect(opencode.detect('global').alreadyConfigured).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Copilot family — copilot-vscode / copilot-cli / copilot-jetbrains (CG-5) +// +// The registry-driven contract suite above covers the shared surface +// (install/idempotency/sibling/uninstall/printConfig). These pin the +// target-specific behavior: OS-specific global paths, `--path` injection +// (copilot-vscode mirrors Cursor), global-only skip semantics (cli + +// jetbrains, Codex pattern), COPILOT_HOME resolution, JSONC comment +// preservation, empty-`servers`-wrapper cleanup, and printConfig parity +// with what install writes. +// --------------------------------------------------------------------------- +describe('Installer targets — Copilot family', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('cop-home'); + tmpCwd = mkTmpDir('cop-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + // printConfig embeds the paste-able snippet after a `# Add to ` + // header — extract and parse just the JSON body. + function snippetJson(out: string): any { + const start = out.indexOf('{'); + expect(start).toBeGreaterThanOrEqual(0); + return JSON.parse(out.slice(start)); + } + + // ---- copilot-vscode ---- + + it('copilot-vscode: local install writes ./.vscode/mcp.json with servers.codegraph and an absolute --path pin', () => { + const t = getTarget('copilot-vscode')!; + const result = t.install('local', { autoAllow: true }); + + const file = path.join(process.cwd(), '.vscode', 'mcp.json'); + expect(result.files[0].path).toBe(file); + expect(result.files[0].action).toBe('created'); + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.servers.codegraph.type).toBe('stdio'); + expect(cfg.servers.codegraph.command).toBe('codegraph'); + // Cursor-mirror: local installs pin the project with an absolute path. + expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp', '--path', process.cwd()]); + // No mcpServers wrapper — VS Code's mcp.json uses `servers`. + expect(cfg.mcpServers).toBeUndefined(); + }); + + it('copilot-vscode: global install pins --path to ${workspaceFolder}', () => { + const t = getTarget('copilot-vscode')!; + const result = t.install('global', { autoAllow: true }); + const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp', '--path', '${workspaceFolder}']); + }); + + it.runIf(process.platform === 'darwin')('copilot-vscode: global path is ~/Library/Application Support/Code/User/mcp.json on macOS', () => { + const t = getTarget('copilot-vscode')!; + const expected = path.join(tmpHome, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + expect(t.describePaths('global')).toEqual([expected]); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].path).toBe(expected); + expect(fs.existsSync(expected)).toBe(true); + }); + + it.runIf(process.platform === 'linux')('copilot-vscode: global path honors XDG_CONFIG_HOME on Linux', () => { + const t = getTarget('copilot-vscode')!; + // setHome() points XDG_CONFIG_HOME at /.config. + const expected = path.join(tmpHome, '.config', 'Code', 'User', 'mcp.json'); + expect(t.describePaths('global')).toEqual([expected]); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].path).toBe(expected); + }); + + it.runIf(process.platform === 'win32')('copilot-vscode: global path is %APPDATA%\\Code\\User\\mcp.json on Windows', () => { + const t = getTarget('copilot-vscode')!; + // setHome() points APPDATA at /.config. + const expected = path.join(process.env.APPDATA!, 'Code', 'User', 'mcp.json'); + expect(t.describePaths('global')).toEqual([expected]); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].path).toBe(expected); + }); + + it('copilot-vscode: supports both global and local locations', () => { + const t = getTarget('copilot-vscode')!; + expect(t.supportsLocation('global')).toBe(true); + expect(t.supportsLocation('local')).toBe(true); + }); + + it('copilot-vscode: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => { + const t = getTarget('copilot-vscode')!; + const dir = path.join(tmpCwd, '.vscode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, [ + '{', + ' // my MCP servers', + ' "servers": {', + ' "other": { "type": "stdio", "command": "other-server" } // keep', + ' }', + '}', + '', + ].join('\n')); + + t.install('local', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('// my MCP servers'); + expect(afterInstall).toContain('// keep'); + expect(afterInstall).toContain('"other-server"'); + expect(afterInstall).toContain('"codegraph"'); + + const second = t.install('local', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); + }); + + it('copilot-vscode: uninstall drops an emptied servers wrapper but keeps the file and its siblings (e.g. inputs)', () => { + const t = getTarget('copilot-vscode')!; + const dir = path.join(tmpCwd, '.vscode'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'mcp.json'); + fs.writeFileSync(file, [ + '{', + ' // prompt-time inputs', + ' "inputs": [{ "id": "api-key", "type": "promptString" }]', + '}', + '', + ].join('\n')); + + t.install('local', { autoAllow: true }); + const result = t.uninstall('local'); + expect(result.files[0].action).toBe('removed'); + + // File survives; our entry and the now-empty `servers` wrapper are gone. + expect(fs.existsSync(file)).toBe(true); + const text = fs.readFileSync(file, 'utf-8'); + expect(text).toContain('// prompt-time inputs'); + const cfg = parseJsonc(text); + expect(cfg.inputs).toBeDefined(); + expect(cfg.servers).toBeUndefined(); + expect(text).not.toContain('codegraph'); + }); + + it('copilot-vscode: uninstall keeps a non-empty servers wrapper (sibling server survives)', () => { + const t = getTarget('copilot-vscode')!; + const file = path.join(tmpCwd, '.vscode', 'mcp.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ + servers: { other: { type: 'stdio', command: 'other-server' } }, + }, null, 2) + '\n'); + + t.install('local', { autoAllow: true }); + t.uninstall('local'); + + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.servers.other).toBeDefined(); + expect(cfg.servers.codegraph).toBeUndefined(); + }); + + it('copilot-vscode: uninstall when never installed reports not-found for both locations, no throw', () => { + const t = getTarget('copilot-vscode')!; + for (const loc of ['global', 'local'] as const) { + const result = t.uninstall(loc); + expect(result.files).toHaveLength(1); + expect(result.files[0].action).toBe('not-found'); + } + }); + + it('copilot-vscode: detect() local reports installed only when a .vscode dir exists', () => { + const t = getTarget('copilot-vscode')!; + expect(t.detect('local').installed).toBe(false); + fs.mkdirSync(path.join(tmpCwd, '.vscode'), { recursive: true }); + expect(t.detect('local').installed).toBe(true); + expect(t.detect('local').alreadyConfigured).toBe(false); + }); + + it('copilot-vscode: detect() global falls back to ~/.vscode (extensions dir) as the installed heuristic', () => { + const t = getTarget('copilot-vscode')!; + expect(t.detect('global').installed).toBe(false); + fs.mkdirSync(path.join(tmpHome, '.vscode'), { recursive: true }); + expect(t.detect('global').installed).toBe(true); + }); + + it('copilot-vscode: printConfig matches what install writes, at both locations', () => { + const t = getTarget('copilot-vscode')!; + for (const loc of ['global', 'local'] as const) { + const printed = snippetJson(t.printConfig(loc)); + const result = t.install(loc, { autoAllow: true }); + const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph); + } + }); + + it('copilot-vscode: install note tells the user to restart VS Code', () => { + const t = getTarget('copilot-vscode')!; + const result = t.install('local', { autoAllow: true }); + expect(result.notes?.join(' ')).toMatch(/[Rr]estart VS Code/); + }); + + // ---- copilot-cli ---- + + it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => { + const t = getTarget('copilot-cli')!; + const result = t.install('global', { autoAllow: true }); + + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + expect(result.files[0].path).toBe(file); + expect(result.files[0].action).toBe('created'); + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.mcpServers.codegraph).toEqual({ + type: 'stdio', + command: 'codegraph', + args: ['serve', '--mcp'], + tools: ['*'], + }); + }); + + it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => { + const t = getTarget('copilot-cli')!; + expect(t.supportsLocation('local')).toBe(false); + expect(t.supportsLocation('global')).toBe(true); + + const install = t.install('local', { autoAllow: true }); + expect(install.files).toEqual([]); + expect(install.notes?.join(' ')).toMatch(/no project-local config/); + + expect(t.uninstall('local').files).toEqual([]); + expect(t.describePaths('local')).toEqual([]); + expect(t.detect('local').installed).toBe(false); + }); + + it('copilot-cli: honors the COPILOT_HOME override for install, detect, and uninstall', () => { + const t = getTarget('copilot-cli')!; + const custom = path.join(tmpHome, 'copilot-custom'); + process.env.COPILOT_HOME = custom; + + const result = t.install('global', { autoAllow: true }); + const expected = path.join(custom, 'mcp-config.json'); + expect(result.files[0].path).toBe(expected); + expect(fs.existsSync(expected)).toBe(true); + expect(t.detect('global').alreadyConfigured).toBe(true); + // The default location was never touched. + expect(fs.existsSync(path.join(tmpHome, '.copilot'))).toBe(false); + + t.uninstall('global'); + expect(t.detect('global').alreadyConfigured).toBe(false); + }); + + it('copilot-cli: uninstall removes only codegraph — sibling server and unrelated keys survive', () => { + const t = getTarget('copilot-cli')!; + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ + mcpServers: { other: { type: 'stdio', command: 'other-server' } }, + banner: 'never', + }, null, 2) + '\n'); + + t.install('global', { autoAllow: true }); + t.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(after.mcpServers.other).toBeDefined(); + expect(after.mcpServers.codegraph).toBeUndefined(); + expect(after.banner).toBe('never'); + }); + + it('copilot-cli: uninstall drops an emptied mcpServers wrapper', () => { + const t = getTarget('copilot-cli')!; + t.install('global', { autoAllow: true }); + t.uninstall('global'); + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + const after = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(after.mcpServers).toBeUndefined(); + }); + + it('copilot-cli: uninstall when never installed reports not-found, no throw', () => { + const t = getTarget('copilot-cli')!; + const result = t.uninstall('global'); + expect(result.files).toHaveLength(1); + expect(result.files[0].action).toBe('not-found'); + + // Same when the file exists but holds no codegraph entry. + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: 'x' } } }) + '\n'); + expect(t.uninstall('global').files[0].action).toBe('not-found'); + }); + + it('copilot-cli: detect() reports installed from the ~/.copilot dir alone', () => { + const t = getTarget('copilot-cli')!; + // The tmp PATH may or may not carry a real `copilot` binary; only + // assert the positive signal we control. + fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + expect(t.detect('global').installed).toBe(true); + expect(t.detect('global').alreadyConfigured).toBe(false); + }); + + it('copilot-cli: printConfig matches what install writes; local variant points at --location=global', () => { + const t = getTarget('copilot-cli')!; + const printed = snippetJson(t.printConfig('global')); + const result = t.install('global', { autoAllow: true }); + const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(printed.mcpServers.codegraph).toEqual(onDisk.mcpServers.codegraph); + + expect(t.printConfig('local')).toMatch(/--location=global/); + }); + + // ---- copilot-jetbrains ---- + + it('copilot-jetbrains: global install writes github-copilot/intellij/mcp.json with the VS Code-compatible servers shape', () => { + const t = getTarget('copilot-jetbrains')!; + const result = t.install('global', { autoAllow: true }); + + // setHome() sets XDG_CONFIG_HOME, honored on every platform. + const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json'); + expect(result.files[0].path).toBe(file); + expect(result.files[0].action).toBe('created'); + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + // Plain entry — no --path injection for this user-global config. + expect(cfg.servers.codegraph).toEqual({ type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }); + expect(cfg.mcpServers).toBeUndefined(); + }); + + it.runIf(process.platform !== 'win32')('copilot-jetbrains: falls back to ~/.config/github-copilot when XDG_CONFIG_HOME is unset', () => { + delete process.env.XDG_CONFIG_HOME; + const t = getTarget('copilot-jetbrains')!; + const expected = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json'); + expect(t.describePaths('global')).toEqual([expected]); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].path).toBe(expected); + }); + + it.runIf(process.platform === 'win32')('copilot-jetbrains: falls back to %LOCALAPPDATA%\\github-copilot on Windows when XDG_CONFIG_HOME is unset', () => { + const prevLocal = process.env.LOCALAPPDATA; + delete process.env.XDG_CONFIG_HOME; + process.env.LOCALAPPDATA = path.join(tmpHome, 'AppData', 'Local'); + try { + const t = getTarget('copilot-jetbrains')!; + const expected = path.join(tmpHome, 'AppData', 'Local', 'github-copilot', 'intellij', 'mcp.json'); + expect(t.describePaths('global')).toEqual([expected]); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].path).toBe(expected); + } finally { + if (prevLocal === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = prevLocal; + } + }); + + it('copilot-jetbrains: is global-only — local install skips with a clear note, uninstall is a no-op', () => { + const t = getTarget('copilot-jetbrains')!; + expect(t.supportsLocation('local')).toBe(false); + expect(t.supportsLocation('global')).toBe(true); + + const install = t.install('local', { autoAllow: true }); + expect(install.files).toEqual([]); + expect(install.notes?.join(' ')).toMatch(/no project-local MCP config/); + + expect(t.uninstall('local').files).toEqual([]); + expect(t.describePaths('local')).toEqual([]); + expect(t.detect('local').installed).toBe(false); + }); + + it('copilot-jetbrains: preserves comments and sibling servers through install + idempotent re-run (JSONC)', () => { + const t = getTarget('copilot-jetbrains')!; + const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, [ + '{', + ' // hand-edited via Settings → Tools → GitHub Copilot', + ' "servers": {', + ' "other": { "type": "stdio", "command": "other-server" }', + ' }', + '}', + '', + ].join('\n')); + + t.install('global', { autoAllow: true }); + const afterInstall = fs.readFileSync(file, 'utf-8'); + expect(afterInstall).toContain('// hand-edited via Settings'); + expect(afterInstall).toContain('"other-server"'); + expect(afterInstall).toContain('"codegraph"'); + + const second = t.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall); + }); + + it('copilot-jetbrains: uninstall removes only codegraph and drops an emptied servers wrapper, keeping the file', () => { + const t = getTarget('copilot-jetbrains')!; + t.install('global', { autoAllow: true }); + const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json'); + + const result = t.uninstall('global'); + expect(result.files[0].action).toBe('removed'); + expect(fs.existsSync(file)).toBe(true); + const cfg = parseJsonc(fs.readFileSync(file, 'utf-8')); + expect(cfg.servers).toBeUndefined(); + }); + + it('copilot-jetbrains: uninstall keeps a sibling server (wrapper not dropped when non-empty)', () => { + const t = getTarget('copilot-jetbrains')!; + const file = path.join(tmpHome, '.config', 'github-copilot', 'intellij', 'mcp.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ + servers: { other: { type: 'stdio', command: 'other-server' } }, + }, null, 2) + '\n'); + + t.install('global', { autoAllow: true }); + t.uninstall('global'); + + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.servers.other).toBeDefined(); + expect(cfg.servers.codegraph).toBeUndefined(); + }); + + it('copilot-jetbrains: uninstall when never installed reports not-found, no throw', () => { + const t = getTarget('copilot-jetbrains')!; + const result = t.uninstall('global'); + expect(result.files).toHaveLength(1); + expect(result.files[0].action).toBe('not-found'); + }); + + it('copilot-jetbrains: detect() reports installed from the intellij config dir', () => { + const t = getTarget('copilot-jetbrains')!; + expect(t.detect('global').installed).toBe(false); + fs.mkdirSync(path.join(tmpHome, '.config', 'github-copilot', 'intellij'), { recursive: true }); + expect(t.detect('global').installed).toBe(true); + expect(t.detect('global').alreadyConfigured).toBe(false); + }); + + it('copilot-jetbrains: printConfig matches what install writes and names the IDE settings path', () => { + const t = getTarget('copilot-jetbrains')!; + const out = t.printConfig('global'); + expect(out).toContain('Settings → Tools → GitHub Copilot'); + const printed = snippetJson(out); + const result = t.install('global', { autoAllow: true }); + const onDisk = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); + expect(printed.servers.codegraph).toEqual(onDisk.servers.codegraph); + + expect(t.printConfig('local')).toMatch(/--location=global/); + }); + + it('copilot-jetbrains: install note tells the user to restart the IDE', () => { + const t = getTarget('copilot-jetbrains')!; + const result = t.install('global', { autoAllow: true }); + expect(result.notes?.join(' ')).toMatch(/[Rr]estart your JetBrains IDE/); + }); + + it('copilot family: all three coexist — uninstalling one leaves the others configured', () => { + const vscode = getTarget('copilot-vscode')!; + const cli = getTarget('copilot-cli')!; + const jetbrains = getTarget('copilot-jetbrains')!; + vscode.install('global', { autoAllow: true }); + cli.install('global', { autoAllow: true }); + jetbrains.install('global', { autoAllow: true }); + + cli.uninstall('global'); + + expect(cli.detect('global').alreadyConfigured).toBe(false); + expect(vscode.detect('global').alreadyConfigured).toBe(true); + expect(jetbrains.detect('global').alreadyConfigured).toBe(true); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index eefb5d907..c90d413fd 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2232,7 +2232,7 @@ program */ program .command('install') - .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') @@ -2332,7 +2332,7 @@ program */ program .command('uninstall') - .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') diff --git a/src/installer/index.ts b/src/installer/index.ts index ace88614b..edeb4ac94 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -3,7 +3,8 @@ * * Multi-target: writes MCP server config + instructions for the * agents the user picks (Claude Code, Cursor, Codex CLI, opencode, - * Hermes Agent, Gemini CLI, Antigravity IDE). + * Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub + * Copilot in VS Code / the Copilot CLI / JetBrains IDEs). * Defaults to the Claude-only behavior for backwards compatibility * when no targets are explicitly chosen and nothing else is detected. * @@ -467,8 +468,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise const sel = await clack.select({ message: 'Remove CodeGraph from all your projects, or just this one?', options: [ - { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro' }, - { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' }, + { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.copilot, ~/.config/github-copilot' }, + { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./.vscode, ./opencode.jsonc, ./.gemini, ./.kiro' }, ], initialValue: 'global' as const, }); diff --git a/src/installer/targets/copilot-cli.ts b/src/installer/targets/copilot-cli.ts new file mode 100644 index 000000000..8c8e3c9ab --- /dev/null +++ b/src/installer/targets/copilot-cli.ts @@ -0,0 +1,166 @@ +/** + * GitHub Copilot CLI target. + * + * - MCP server entry to `~/.copilot/mcp-config.json` under the + * `mcpServers` key (same wrapper as Claude/Cursor). Entry shape per + * the GitHub docs: `{ "type": "stdio", "command", "args", "tools" }` + * — `type` accepts `"local"` or `"stdio"`; we write `"stdio"` (the + * standard MCP name, recommended by the docs for cross-client + * compatibility). `"tools": ["*"]` mirrors the docs' example and is + * the documented default. + * - The config dir is `~/.copilot` unless the user moved it via + * `COPILOT_HOME` (documented override) — we honor it so install and + * detect follow the CLI's own resolution. + * + * Copilot CLI as of 2026-07 has no project-local MCP config — per-repo + * config (`.github/mcp.json`) is an open feature request + * (github/copilot-cli#2528). `supportsLocation('local')` returns false; + * the orchestrator skips this target for local installs with a clear + * message (same pattern as Codex). + * + * The file is machine-written by the CLI's own `/mcp add` flow, so it's + * plain JSON — no JSONC handling needed; surgical edits go through the + * shared read/mutate/write helpers (Cursor pattern), preserving sibling + * servers. + * + * No instructions file (MCP `initialize` instructions are the single + * source of truth, #529) and no permissions concept — `autoAllow` is + * silently ignored. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; + +function configDir(): string { + const override = process.env.COPILOT_HOME; + if (override && override.trim().length > 0) return override; + return path.join(os.homedir(), '.copilot'); +} + +function mcpConfigPath(): string { + return path.join(configDir(), 'mcp-config.json'); +} + +/** + * Best-effort check that the `copilot` binary is reachable on PATH. + * A plain fs scan (no shell-out) — cheap enough to run inside + * `detectAll()` for the multiselect prompt. + */ +function copilotOnPath(): boolean { + const pathVar = process.env.PATH || ''; + const exts = process.platform === 'win32' + ? ['.exe', '.cmd', '.bat', '.ps1'] + : ['']; + for (const dir of pathVar.split(path.delimiter)) { + if (!dir) continue; + for (const ext of exts) { + try { + if (fs.existsSync(path.join(dir, 'copilot' + ext))) return true; + } catch { /* ignore unreadable PATH entries */ } + } + } + return false; +} + +function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } { + const base = getMcpServerConfig(); + return { ...base, tools: ['*'] }; +} + +class CopilotCliTarget implements AgentTarget { + readonly id = 'copilot-cli' as const; + readonly displayName = 'GitHub Copilot CLI'; + readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers'; + + supportsLocation(loc: Location): boolean { + return loc === 'global'; + } + + detect(loc: Location): DetectionResult { + if (loc !== 'global') { + return { installed: false, alreadyConfigured: false }; + } + const file = mcpConfigPath(); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(configDir()) || copilotOnPath(); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc !== 'global') { + return { + files: [], + notes: ['Copilot CLI has no project-local config — re-run with --location=global to install.'], + }; + } + return { + files: [writeMcpEntry()], + notes: ['Restart any running Copilot CLI session to pick up the MCP server.'], + }; + } + + uninstall(loc: Location): WriteResult { + if (loc !== 'global') return { files: [] }; + + const file = mcpConfigPath(); + if (!fs.existsSync(file)) { + return { files: [{ path: file, action: 'not-found' }] }; + } + const config = readJsonFile(file); + if (!config.mcpServers?.codegraph) { + return { files: [{ path: file, action: 'not-found' }] }; + } + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + return { files: [{ path: file, action: 'removed' }] }; + } + + printConfig(loc: Location): string { + if (loc !== 'global') { + return '# Copilot CLI has no project-local config — use --location=global.\n'; + } + const snippet = JSON.stringify({ mcpServers: { codegraph: buildCopilotMcpConfig() } }, null, 2); + return `# Add to ${mcpConfigPath()}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + if (loc !== 'global') return []; + return [mcpConfigPath()]; + } +} + +function writeMcpEntry(): WriteResult['files'][number] { + const file = mcpConfigPath(); + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = buildCopilotMcpConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const existed = fs.existsSync(file); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action: existed ? 'updated' : 'created' }; +} + +export const copilotCliTarget: AgentTarget = new CopilotCliTarget(); diff --git a/src/installer/targets/copilot-jetbrains.ts b/src/installer/targets/copilot-jetbrains.ts new file mode 100644 index 000000000..61ab5a24f --- /dev/null +++ b/src/installer/targets/copilot-jetbrains.ts @@ -0,0 +1,230 @@ +/** + * JetBrains IDEs (GitHub Copilot plugin) target. + * + * - MCP server entry to the plugin's user-level `mcp.json`, which + * lives under the shared `github-copilot` config dir (the same dir + * the Copilot ecosystem uses for `hosts.json`): + * + * macOS/Linux: $XDG_CONFIG_HOME|~/.config/github-copilot/intellij/mcp.json + * Windows: %LOCALAPPDATA%\github-copilot\intellij\mcp.json + * + * `$XDG_CONFIG_HOME` is honored on every platform when set — + * matching the plugin family's own resolution (copilot.vim / + * copilot-language-server check it before the OS default). + * - Shape is VS Code-compatible: `{ "servers": { "": { "type": + * "stdio", "command", "args" } } }` — the plugin documents mcp.json + * parity with `.vscode/mcp.json`. + * - **Global-only.** The plugin reads exactly one user-level file; a + * project-level mcp.json is an open feature request + * (microsoft/copilot-intellij-feedback#701, still open 2026-07). + * `supportsLocation('local')` returns false so the orchestrator + * skips local installs with a clear message (Codex pattern). + * - No `--path` injection: the config is user-global and the plugin + * documents no `${workspaceFolder}`-style variable expansion for + * this file, so we ship the plain entry and let the MCP server + * resolve the project from the client's roots/cwd as with other + * global installs. + * - No instructions file (MCP `initialize` instructions are the + * single source of truth, #529) and no permissions concept — + * `autoAllow` is silently ignored. + * + * The IDE opens this file in a JSON editor for hand-editing (Settings → + * Tools → GitHub Copilot → MCP → Configure), so reads + writes go + * through `jsonc-parser` — surgical edits that preserve sibling + * servers, user comments, and formatting (same approach as the + * copilot-vscode target). + * + * The plugin only re-reads mcp.json on IDE restart + * (microsoft/copilot-intellij-feedback#1139) — hence the restart note. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, +} from './shared'; + +/** + * The `github-copilot` config root, resolved the way the Copilot + * plugin family resolves it: `$XDG_CONFIG_HOME` first on every + * platform, then `%LOCALAPPDATA%` on Windows, then `~/.config`. + */ +function copilotConfigRoot(): string { + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg && xdg.trim().length > 0) { + return path.join(xdg, 'github-copilot'); + } + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.trim().length > 0 + ? process.env.LOCALAPPDATA + : path.join(os.homedir(), 'AppData', 'Local'); + return path.join(localAppData, 'github-copilot'); + } + return path.join(os.homedir(), '.config', 'github-copilot'); +} + +function intellijDir(): string { + return path.join(copilotConfigRoot(), 'intellij'); +} + +function mcpJsonPath(): string { + return path.join(intellijDir(), 'mcp.json'); +} + +/** + * Best-effort "a JetBrains IDE exists here" heuristic for the + * multiselect default — the per-OS dir every JetBrains IDE creates on + * first launch. False positives (IDE without the Copilot plugin) are + * acceptable per the `DetectionResult` contract. + */ +function jetbrainsConfigDirExists(): boolean { + const home = os.homedir(); + if (process.platform === 'darwin') { + return fs.existsSync(path.join(home, 'Library', 'Application Support', 'JetBrains')); + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0 + ? process.env.APPDATA + : path.join(home, 'AppData', 'Roaming'); + return fs.existsSync(path.join(appData, 'JetBrains')); + } + const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0 + ? process.env.XDG_CONFIG_HOME + : path.join(home, '.config'); + return fs.existsSync(path.join(xdg, 'JetBrains')); +} + +function readConfigText(file: string): string { + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function parseConfig(text: string): Record { + if (!text.trim()) return {}; + const errors: any[] = []; + const result = parseJsonc(text, errors, { allowTrailingComma: true }); + if (result == null || typeof result !== 'object' || Array.isArray(result)) { + return {}; + } + return result as Record; +} + +const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; + +class CopilotJetbrainsTarget implements AgentTarget { + readonly id = 'copilot-jetbrains' as const; + readonly displayName = 'JetBrains IDEs (Copilot plugin)'; + readonly docsUrl = 'https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp/extend-copilot-chat-with-mcp'; + + supportsLocation(loc: Location): boolean { + return loc === 'global'; + } + + detect(loc: Location): DetectionResult { + if (loc !== 'global') { + return { installed: false, alreadyConfigured: false }; + } + const file = mcpJsonPath(); + const config = parseConfig(readConfigText(file)); + const alreadyConfigured = !!config.servers?.codegraph; + // The `intellij/` subdir is created by the Copilot plugin itself; + // fall back to "some JetBrains IDE is installed" for first-time + // plugin users. + const installed = fs.existsSync(intellijDir()) || jetbrainsConfigDirExists(); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc !== 'global') { + return { + files: [], + notes: ['The JetBrains Copilot plugin has no project-local MCP config — re-run with --location=global to install.'], + }; + } + return { + files: [writeMcpEntry()], + notes: ['Restart your JetBrains IDE — the Copilot plugin only reads mcp.json on startup.'], + }; + } + + uninstall(loc: Location): WriteResult { + if (loc !== 'global') return { files: [] }; + return { files: [removeMcpEntry()] }; + } + + printConfig(loc: Location): string { + if (loc !== 'global') { + return '# The JetBrains Copilot plugin has no project-local MCP config — use --location=global.\n'; + } + const snippet = JSON.stringify({ servers: { codegraph: getMcpServerConfig() } }, null, 2); + return `# Add to ${mcpJsonPath()}\n# (Settings → Tools → GitHub Copilot → Model Context Protocol → Configure)\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + if (loc !== 'global') return []; + return [mcpJsonPath()]; + } +} + +function writeMcpEntry(): WriteResult['files'][number] { + const file = mcpJsonPath(); + const existed = fs.existsSync(file); + let text = readConfigText(file); + if (!text.trim()) text = '{}\n'; + + const config = parseConfig(text); + const before = config.servers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + + // Surgical edit — preserves comments, formatting, and sibling + // servers ("servers" is created when missing). + const edits = modify(text, ['servers', 'codegraph'], after, { + formattingOptions: FORMATTING, + }); + const updated = applyEdits(text, edits); + atomicWriteFileSync(file, updated); + + return { path: file, action: existed ? 'updated' : 'created' }; +} + +function removeMcpEntry(): WriteResult['files'][number] { + const file = mcpJsonPath(); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + const text = readConfigText(file); + const config = parseConfig(text); + if (!config.servers?.codegraph) return { path: file, action: 'not-found' }; + + let edits = modify(text, ['servers', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + let updated = applyEdits(text, edits); + + // Drop an emptied `servers` wrapper; the file itself is left in + // place — the plugin owns it and siblings may remain. + const afterParsed = parseConfig(updated); + if (afterParsed.servers && typeof afterParsed.servers === 'object' && + Object.keys(afterParsed.servers).length === 0) { + edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING }); + updated = applyEdits(updated, edits); + } + + atomicWriteFileSync(file, updated); + return { path: file, action: 'removed' }; +} + +export const copilotJetbrainsTarget: AgentTarget = new CopilotJetbrainsTarget(); diff --git a/src/installer/targets/copilot-vscode.ts b/src/installer/targets/copilot-vscode.ts new file mode 100644 index 000000000..ee8e76296 --- /dev/null +++ b/src/installer/targets/copilot-vscode.ts @@ -0,0 +1,202 @@ +/** + * VS Code (GitHub Copilot Chat) target. + * + * - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped) + * or the user-level `mcp.json` in the VS Code User dir (global): + * + * macOS: ~/Library/Application Support/Code/User/mcp.json + * Windows: %APPDATA%\Code\User\mcp.json + * Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json + * + * VS Code moved MCP config out of settings.json into this dedicated + * `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is + * `{ "servers": { "": { "type": "stdio", "command", "args" } } }` + * — note `servers`, not the `mcpServers` wrapper Claude/Cursor use. + * - No instructions file: Copilot Chat consumes the MCP `initialize` + * instructions, the single source of truth (#529). + * - No permissions concept — `autoAllow` is silently ignored. + * + * ## Why we inject `--path` (mirrors Cursor) + * + * VS Code's docs don't specify the working directory stdio MCP servers + * are launched with, and (like Cursor) we can't rely on it being the + * workspace root. Rather than depend on undocumented cwd behavior we + * pin the project explicitly: + * + * - `local` install: absolute path (known at install time). + * - `global` install: `${workspaceFolder}` — VS Code expands its + * standard variables inside mcp.json, giving per-workspace behavior + * from a single user-level config. + * + * ## JSONC + * + * VS Code parses its config files as JSONC (comments + trailing commas + * allowed), so reads + writes go through `jsonc-parser` — surgical + * edits that preserve sibling servers, user comments, and formatting + * across install / re-install / uninstall (same approach as opencode). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, +} from './shared'; + +function vscodeUserDir(): string { + const home = os.homedir(); + if (process.platform === 'win32') { + const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0 + ? process.env.APPDATA + : path.join(home, 'AppData', 'Roaming'); + return path.join(appData, 'Code', 'User'); + } + if (process.platform === 'darwin') { + return path.join(home, 'Library', 'Application Support', 'Code', 'User'); + } + const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0 + ? process.env.XDG_CONFIG_HOME + : path.join(home, '.config'); + return path.join(xdg, 'Code', 'User'); +} + +function mcpJsonPath(loc: Location): string { + return loc === 'global' + ? path.join(vscodeUserDir(), 'mcp.json') + : path.join(process.cwd(), '.vscode', 'mcp.json'); +} + +/** + * Build the codegraph server entry for VS Code at the given location. + * Shared `{type, command, args}` shape plus the `--path` pin — see + * file header for why we don't trust VS Code's launch cwd. + */ +function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } { + const base = getMcpServerConfig(); + const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}'; + return { ...base, args: [...base.args, '--path', pathArg] }; +} + +function readConfigText(file: string): string { + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function parseConfig(text: string): Record { + if (!text.trim()) return {}; + const errors: any[] = []; + const result = parseJsonc(text, errors, { allowTrailingComma: true }); + if (result == null || typeof result !== 'object' || Array.isArray(result)) { + return {}; + } + return result as Record; +} + +const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; + +class CopilotVscodeTarget implements AgentTarget { + readonly id = 'copilot-vscode' as const; + readonly displayName = 'VS Code (Copilot Chat)'; + readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = parseConfig(readConfigText(file)); + const alreadyConfigured = !!config.servers?.codegraph; + // "Installed" heuristic: the VS Code User dir (created on first + // launch) or ~/.vscode (extensions dir) for global; an existing + // .vscode/ dir in the project for local. + const installed = loc === 'global' + ? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode')) + : fs.existsSync(path.join(process.cwd(), '.vscode')); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + return { + files: [writeMcpEntry(loc)], + notes: ['Restart VS Code for MCP changes to take effect.'], + }; + } + + uninstall(loc: Location): WriteResult { + return { files: [removeMcpEntry(loc)] }; + } + + printConfig(loc: Location): string { + const target = mcpJsonPath(loc); + const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const existed = fs.existsSync(file); + let text = readConfigText(file); + if (!text.trim()) text = '{}\n'; + + const config = parseConfig(text); + const before = config.servers?.codegraph; + const after = buildVscodeServerEntry(loc); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + + // Surgical edit — preserves comments, formatting, and sibling + // servers ("servers" is created when missing). + const edits = modify(text, ['servers', 'codegraph'], after, { + formattingOptions: FORMATTING, + }); + const updated = applyEdits(text, edits); + atomicWriteFileSync(file, updated); + + return { path: file, action: existed ? 'updated' : 'created' }; +} + +function removeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + const text = readConfigText(file); + const config = parseConfig(text); + if (!config.servers?.codegraph) return { path: file, action: 'not-found' }; + + let edits = modify(text, ['servers', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + let updated = applyEdits(text, edits); + + // Drop an emptied `servers` wrapper; the file itself is left in + // place — VS Code recreates/reads it and siblings like `inputs` + // may remain. + const afterParsed = parseConfig(updated); + if (afterParsed.servers && typeof afterParsed.servers === 'object' && + Object.keys(afterParsed.servers).length === 0) { + edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING }); + updated = applyEdits(updated, edits); + } + + atomicWriteFileSync(file, updated); + return { path: file, action: 'removed' }; +} + +export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..3798b39ad 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,9 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { copilotVscodeTarget } from './copilot-vscode'; +import { copilotCliTarget } from './copilot-cli'; +import { copilotJetbrainsTarget } from './copilot-jetbrains'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +29,9 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + copilotVscodeTarget, + copilotCliTarget, + copilotJetbrainsTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..022ab28e8 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains'; /** * Result of `target.detect(location)`. From 234dfe60cd9cdda49f7c73e033be5a820c712508 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 23 Jul 2026 21:16:32 -0500 Subject: [PATCH 03/87] fix(installer): copilot-cli detection false-positived on VS Code's ~/.copilot/ide locks The VS Code Copilot Chat extension writes MCP socket-handoff lock files into ~/.copilot/ide/ on launch, so `existsSync(~/.copilot)` reported the Copilot CLI as installed on any machine that merely has the VS Code extension (caught live on the maintainer's Mac). Detection now counts the dir as a CLI footprint only when it holds something besides `ide`. Also: uninstalling a from-scratch install now deletes mcp-config.json instead of leaving a `{}` husk that would keep detect() reporting the CLI as installed. Co-Authored-By: Claude Fable 5 --- __tests__/installer-targets.test.ts | 45 ++++++++++++++++++++++++++-- src/installer/targets/copilot-cli.ts | 30 +++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 29353547b..2e8f70a4e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -2162,13 +2162,26 @@ describe('Installer targets — Copilot family', () => { expect(after.banner).toBe('never'); }); - it('copilot-cli: uninstall drops an emptied mcpServers wrapper', () => { + it('copilot-cli: uninstall of a from-scratch install deletes the file — no `{}` husk to fool detect()', () => { const t = getTarget('copilot-cli')!; t.install('global', { autoAllow: true }); t.uninstall('global'); const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + // A leftover empty mcp-config.json would count as a CLI footprint + // and keep the target showing as detected after uninstall. + expect(fs.existsSync(file)).toBe(false); + }); + + it('copilot-cli: uninstall keeps the file when unrelated top-level keys remain', () => { + const t = getTarget('copilot-cli')!; + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ banner: 'never' }, null, 2) + '\n'); + t.install('global', { autoAllow: true }); + t.uninstall('global'); const after = JSON.parse(fs.readFileSync(file, 'utf-8')); expect(after.mcpServers).toBeUndefined(); + expect(after.banner).toBe('never'); }); it('copilot-cli: uninstall when never installed reports not-found, no throw', () => { @@ -2184,15 +2197,41 @@ describe('Installer targets — Copilot family', () => { expect(t.uninstall('global').files[0].action).toBe('not-found'); }); - it('copilot-cli: detect() reports installed from the ~/.copilot dir alone', () => { + it('copilot-cli: detect() reports installed from CLI artifacts in ~/.copilot', () => { const t = getTarget('copilot-cli')!; // The tmp PATH may or may not carry a real `copilot` binary; only - // assert the positive signal we control. + // assert the positive signal we control. The CLI writes config.json + // on first run — that's the footprint. fs.mkdirSync(path.join(tmpHome, '.copilot'), { recursive: true }); + fs.writeFileSync(path.join(tmpHome, '.copilot', 'config.json'), '{}'); expect(t.detect('global').installed).toBe(true); expect(t.detect('global').alreadyConfigured).toBe(false); }); + it('copilot-cli: detect() is NOT fooled by the VS Code extension\'s ~/.copilot/ide/ locks', () => { + // The VS Code Copilot Chat extension writes MCP socket-handoff lock + // files into ~/.copilot/ide/ on every launch — a machine with only + // the extension has ~/.copilot with a lone `ide` entry and no CLI. + const t = getTarget('copilot-cli')!; + const ideDir = path.join(tmpHome, '.copilot', 'ide'); + fs.mkdirSync(ideDir, { recursive: true }); + fs.writeFileSync(path.join(ideDir, 'some-uuid.lock'), '{"socketPath":"/tmp/mcp.sock"}'); + + // Pin PATH to an empty dir so a real `copilot` binary on the host + // can't turn this negative assertion into a false failure. + const prevPath = process.env.PATH; + process.env.PATH = ideDir; + try { + expect(t.detect('global').installed).toBe(false); + + // An empty ~/.copilot (no CLI footprint at all) is also not enough. + fs.rmSync(ideDir, { recursive: true }); + expect(t.detect('global').installed).toBe(false); + } finally { + process.env.PATH = prevPath; + } + }); + it('copilot-cli: printConfig matches what install writes; local variant points at --location=global', () => { const t = getTarget('copilot-cli')!; const printed = snippetJson(t.printConfig('global')); diff --git a/src/installer/targets/copilot-cli.ts b/src/installer/targets/copilot-cli.ts index 8c8e3c9ab..5fa166e40 100644 --- a/src/installer/targets/copilot-cli.ts +++ b/src/installer/targets/copilot-cli.ts @@ -55,6 +55,25 @@ function mcpConfigPath(): string { return path.join(configDir(), 'mcp-config.json'); } +/** + * `~/.copilot` existing is NOT proof the CLI is installed: the VS Code + * Copilot Chat extension drops MCP socket-handoff lock files into + * `~/.copilot/ide/` on launch, so a machine with only the VS Code + * extension still has the dir (with a lone `ide` entry). Count the dir + * as a CLI footprint only when it holds anything besides `ide` — the + * CLI writes `config.json` (and later `mcp-config.json`, history state) + * on first run. + */ +function cliConfigDirPresent(): boolean { + let entries: string[]; + try { + entries = fs.readdirSync(configDir()); + } catch { + return false; + } + return entries.some((e) => e !== 'ide'); +} + /** * Best-effort check that the `copilot` binary is reachable on PATH. * A plain fs scan (no shell-out) — cheap enough to run inside @@ -97,7 +116,7 @@ class CopilotCliTarget implements AgentTarget { const file = mcpConfigPath(); const config = readJsonFile(file); const alreadyConfigured = !!config.mcpServers?.codegraph; - const installed = fs.existsSync(configDir()) || copilotOnPath(); + const installed = cliConfigDirPresent() || copilotOnPath(); return { installed, alreadyConfigured, configPath: file }; } @@ -129,7 +148,14 @@ class CopilotCliTarget implements AgentTarget { if (Object.keys(config.mcpServers).length === 0) { delete config.mcpServers; } - writeJsonFile(file, config); + if (Object.keys(config).length === 0) { + // Nothing left but the `{}` we'd write back — delete the file so + // uninstall fully reverses a from-scratch install. A leftover + // empty file would keep detect() reporting the CLI as installed. + fs.unlinkSync(file); + } else { + writeJsonFile(file, config); + } return { files: [{ path: file, action: 'removed' }] }; } From 73313213e105a8ff03b798b641b7ef2d505520f3 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 23 Jul 2026 21:23:35 -0500 Subject: [PATCH 04/87] fix(installer): warn that copilot-vscode global installs need an open folder VS Code refuses to start a user-level MCP server whose entry uses ${workspaceFolder} in a window with no folder open, surfacing only a cryptic "Variable workspaceFolder can not be resolved" toast (hit live during validation). Global installs now note this up front. Co-Authored-By: Claude Fable 5 --- __tests__/installer-targets.test.ts | 10 ++++++++++ src/installer/targets/copilot-vscode.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 2e8f70a4e..3ee37c7a9 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -2095,6 +2095,16 @@ describe('Installer targets — Copilot family', () => { expect(result.notes?.join(' ')).toMatch(/[Rr]estart VS Code/); }); + it('copilot-vscode: global install warns that ${workspaceFolder} needs an open folder; local does not', () => { + const t = getTarget('copilot-vscode')!; + // VS Code refuses to start a user-level server whose entry uses + // ${workspaceFolder} when no folder is open — surface that up front. + const globalNotes = t.install('global', { autoAllow: true }).notes?.join(' '); + expect(globalNotes).toMatch(/open a folder/i); + const localNotes = t.install('local', { autoAllow: true }).notes?.join(' '); + expect(localNotes).not.toMatch(/open a folder/i); + }); + // ---- copilot-cli ---- it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => { diff --git a/src/installer/targets/copilot-vscode.ts b/src/installer/targets/copilot-vscode.ts index ee8e76296..985acede4 100644 --- a/src/installer/targets/copilot-vscode.ts +++ b/src/installer/targets/copilot-vscode.ts @@ -127,9 +127,16 @@ class CopilotVscodeTarget implements AgentTarget { } install(loc: Location, _opts: InstallOptions): WriteResult { + const notes = ['Restart VS Code for MCP changes to take effect.']; + if (loc === 'global') { + // The global entry pins --path via ${workspaceFolder}; VS Code + // refuses to start it in a window with no folder open, with a + // cryptic "Variable workspaceFolder can not be resolved" toast. + notes.push('VS Code: the server starts per-workspace — open a folder (File → Open Folder) before starting it; a no-folder window reports "Variable workspaceFolder can not be resolved".'); + } return { files: [writeMcpEntry(loc)], - notes: ['Restart VS Code for MCP changes to take effect.'], + notes, }; } From 9769d6be0fcaad9305d8456de5514ab620c9823b Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 23 Jul 2026 21:29:47 -0500 Subject: [PATCH 05/87] =?UTF-8?q?fix(installer):=20copilot-vscode=20global?= =?UTF-8?q?=20entry=20drops=20${workspaceFolder}=20=E2=80=94=20VS=20Code?= =?UTF-8?q?=20toasts=20an=20error=20in=20every=20folderless=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user-level mcp.json entry using ${workspaceFolder} makes VS Code refuse to start the server in ANY window without a folder open (loose files, welcome tab), toasting "Variable workspaceFolder can not be resolved" — recurring error-noise, hit live during validation. The pin was never needed for VS Code: unlike Cursor, VS Code documents stdio-server cwd as the workspace folder, and the codegraph server resolves its project via roots/list with a cwd fallback. Global entries are now variable-free (`serve --mcp`); local installs keep the absolute --path. This supersedes the "open a folder" install note from the previous commit, which is removed again. Co-Authored-By: Claude Fable 5 --- __tests__/installer-targets.test.ts | 20 +++++------ src/installer/targets/copilot-vscode.ts | 45 +++++++++++++------------ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 3ee37c7a9..964492964 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -1945,11 +1945,18 @@ describe('Installer targets — Copilot family', () => { expect(cfg.mcpServers).toBeUndefined(); }); - it('copilot-vscode: global install pins --path to ${workspaceFolder}', () => { + it('copilot-vscode: global install writes a variable-free entry — no --path, no ${workspaceFolder}', () => { + // VS Code refuses to start a user-level server whose entry uses + // ${workspaceFolder} in any window with no folder open, toasting + // "Variable workspaceFolder can not be resolved" (hit live). VS Code + // documents cwd = workspace folder for stdio servers, and the + // codegraph server resolves the project from roots/cwd — so the + // global entry must carry no --path and no variables at all. const t = getTarget('copilot-vscode')!; const result = t.install('global', { autoAllow: true }); const cfg = JSON.parse(fs.readFileSync(result.files[0].path, 'utf-8')); - expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp', '--path', '${workspaceFolder}']); + expect(cfg.servers.codegraph.args).toEqual(['serve', '--mcp']); + expect(JSON.stringify(cfg)).not.toContain('${'); }); it.runIf(process.platform === 'darwin')('copilot-vscode: global path is ~/Library/Application Support/Code/User/mcp.json on macOS', () => { @@ -2095,15 +2102,6 @@ describe('Installer targets — Copilot family', () => { expect(result.notes?.join(' ')).toMatch(/[Rr]estart VS Code/); }); - it('copilot-vscode: global install warns that ${workspaceFolder} needs an open folder; local does not', () => { - const t = getTarget('copilot-vscode')!; - // VS Code refuses to start a user-level server whose entry uses - // ${workspaceFolder} when no folder is open — surface that up front. - const globalNotes = t.install('global', { autoAllow: true }).notes?.join(' '); - expect(globalNotes).toMatch(/open a folder/i); - const localNotes = t.install('local', { autoAllow: true }).notes?.join(' '); - expect(localNotes).not.toMatch(/open a folder/i); - }); // ---- copilot-cli ---- diff --git a/src/installer/targets/copilot-vscode.ts b/src/installer/targets/copilot-vscode.ts index 985acede4..6ac525e73 100644 --- a/src/installer/targets/copilot-vscode.ts +++ b/src/installer/targets/copilot-vscode.ts @@ -16,17 +16,24 @@ * instructions, the single source of truth (#529). * - No permissions concept — `autoAllow` is silently ignored. * - * ## Why we inject `--path` (mirrors Cursor) + * ## Why `--path` only for local installs (NOT the Cursor pattern) * - * VS Code's docs don't specify the working directory stdio MCP servers - * are launched with, and (like Cursor) we can't rely on it being the - * workspace root. Rather than depend on undocumented cwd behavior we - * pin the project explicitly: + * Unlike Cursor, VS Code DOCUMENTS the launch cwd for stdio MCP + * servers: "Working directory for the server command. Defaults to the + * workspace folder when run in a workspace" (mcp-configuration + * reference). The codegraph server resolves its project via the MCP + * roots/list dance with a cwd fallback, so cwd alone is sufficient: * - * - `local` install: absolute path (known at install time). - * - `global` install: `${workspaceFolder}` — VS Code expands its - * standard variables inside mcp.json, giving per-workspace behavior - * from a single user-level config. + * - `local` install: absolute `--path` (known at install time) — + * deterministic, and free of variables. + * - `global` install: NO `--path`. Do not be tempted to pin it with + * `${workspaceFolder}`: VS Code refuses to start a user-level + * server whose entry uses that variable whenever a window has no + * folder open (loose files, welcome tab), surfacing an error toast + * "Variable workspaceFolder can not be resolved" in every such + * window — exactly the error-noise that teaches users to disable + * the server. With no `--path`, a folderless window still starts + * the server fine and it serves the "no project" guidance. * * ## JSONC * @@ -78,13 +85,16 @@ function mcpJsonPath(loc: Location): string { /** * Build the codegraph server entry for VS Code at the given location. - * Shared `{type, command, args}` shape plus the `--path` pin — see - * file header for why we don't trust VS Code's launch cwd. + * Local installs pin `--path`; global installs rely on VS Code's + * documented workspace-folder cwd — see file header for why the global + * entry must stay variable-free. */ function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } { const base = getMcpServerConfig(); - const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}'; - return { ...base, args: [...base.args, '--path', pathArg] }; + if (loc === 'local') { + return { ...base, args: [...base.args, '--path', process.cwd()] }; + } + return { ...base, args: [...base.args] }; } function readConfigText(file: string): string { @@ -127,16 +137,9 @@ class CopilotVscodeTarget implements AgentTarget { } install(loc: Location, _opts: InstallOptions): WriteResult { - const notes = ['Restart VS Code for MCP changes to take effect.']; - if (loc === 'global') { - // The global entry pins --path via ${workspaceFolder}; VS Code - // refuses to start it in a window with no folder open, with a - // cryptic "Variable workspaceFolder can not be resolved" toast. - notes.push('VS Code: the server starts per-workspace — open a folder (File → Open Folder) before starting it; a no-folder window reports "Variable workspaceFolder can not be resolved".'); - } return { files: [writeMcpEntry(loc)], - notes, + notes: ['Restart VS Code for MCP changes to take effect.'], }; } From 0682137a429b25a8a547c37fb1d4874f9f79befa Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 31 Jul 2026 17:12:28 -0500 Subject: [PATCH 06/87] fix(installer): write the Claude prompt hook as codegraph.cmd on Windows (#1466) (#1489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standalone bundle's bin dir exposes only codegraph.cmd, and Claude Code executes UserPromptSubmit hooks through Git Bash, which applies no PATHEXT — so the bare `codegraph prompt-hook` the installer wrote was "command not found" (exit 127) on every prompt. Write the platform-correct spelling, recognize both spellings on uninstall/opt-out, and self-heal an installer-written entry from the other platform in place on install/upgrade re-runs (npx/hand-edited variants stay untouched). Reproduced and validated on the Windows VM: bare form exits 127 under Git Bash on a standalone-only PATH, codegraph.cmd exits 0; full installer suite (165 tests, including the new migration coverage) green on Windows + macOS. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 3 ++ __tests__/installer-targets.test.ts | 55 ++++++++++++++++++++++++----- src/installer/targets/claude.ts | 41 ++++++++++++++++++--- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..fc1a83b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 6db793d65..e6a363e96 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -1144,6 +1144,11 @@ describe('Installer targets — partial-state idempotency', () => { // Opt-in (default-yes in the installer) UserPromptSubmit hook that runs // `codegraph prompt-hook`. Must write/remove surgically, be idempotent, and // round-trip an opt-out — without disturbing the user's own hooks. + // Platform-aware since #1466: Windows writes `codegraph.cmd prompt-hook` + // (Git Bash applies no PATHEXT, so the bare form is exit 127 there), and + // install self-heals the other platform's spelling in place. + const HOOK_CMD = process.platform === 'win32' ? 'codegraph.cmd prompt-hook' : 'codegraph prompt-hook'; + const OTHER_PLATFORM_HOOK_CMD = process.platform === 'win32' ? 'codegraph prompt-hook' : 'codegraph.cmd prompt-hook'; const promptCommands = (s: any): string[] => (s.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); @@ -1151,7 +1156,7 @@ describe('Installer targets — partial-state idempotency', () => { const claude = getTarget('claude')!; claude.install('global', { autoAllow: true, promptHook: true }); const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); - expect(promptCommands(s)).toContain('codegraph prompt-hook'); + expect(promptCommands(s)).toContain(HOOK_CMD); expect(s.permissions?.allow).toContain('mcp__codegraph__*'); }); @@ -1159,7 +1164,7 @@ describe('Installer targets — partial-state idempotency', () => { const claude = getTarget('claude')!; claude.install('global', { autoAllow: true }); const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); - expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + expect(promptCommands(s)).not.toContain(HOOK_CMD); }); it('claude: install with promptHook:true is idempotent (no duplicate, byte-identical re-run)', () => { @@ -1170,7 +1175,7 @@ describe('Installer targets — partial-state idempotency', () => { claude.install('global', { autoAllow: true, promptHook: true }); expect(fs.readFileSync(file, 'utf-8')).toBe(first); const s = JSON.parse(first); - expect(promptCommands(s).filter((c: string) => c === 'codegraph prompt-hook')).toHaveLength(1); + expect(promptCommands(s).filter((c: string) => c === HOOK_CMD)).toHaveLength(1); }); it('claude: install with promptHook:false strips a hook a prior install wrote (opt-out round-trips)', () => { @@ -1178,7 +1183,7 @@ describe('Installer targets — partial-state idempotency', () => { claude.install('global', { autoAllow: true, promptHook: true }); claude.install('global', { autoAllow: true, promptHook: false }); const s = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf-8')); - expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + expect(promptCommands(s)).not.toContain(HOOK_CMD); }); it('claude: writePromptHookEntry preserves a sibling UserPromptSubmit hook', () => { @@ -1187,14 +1192,37 @@ describe('Installer targets — partial-state idempotency', () => { }); expect(writePromptHookEntry('global').action).toBe('updated'); const s = JSON.parse(fs.readFileSync(file, 'utf-8')); - expect(promptCommands(s)).toEqual(['my-own-hook', 'codegraph prompt-hook']); + expect(promptCommands(s)).toEqual(['my-own-hook', HOOK_CMD]); + }); + + it('claude: writePromptHookEntry migrates the other platform\'s spelling in place (#1466 self-heal)', () => { + const file = seedSettings('global', { + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }] }, + }); + expect(writePromptHookEntry('global').action).toBe('updated'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).toEqual([HOOK_CMD]); + // A re-run after migration is byte-identical. + const healed = fs.readFileSync(file, 'utf-8'); + expect(writePromptHookEntry('global').action).toBe('unchanged'); + expect(fs.readFileSync(file, 'utf-8')).toBe(healed); + }); + + it('claude: writePromptHookEntry leaves an npx-form hook untouched (no duplicate, no rewrite)', () => { + const npxCmd = 'npx @colbymchenry/codegraph prompt-hook'; + const file = seedSettings('global', { + hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: npxCmd }] }] }, + }); + expect(writePromptHookEntry('global').action).toBe('unchanged'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).toEqual([npxCmd]); }); it('claude: uninstall removes the prompt hook but keeps the user\'s sibling', () => { const file = seedSettings('global', { hooks: { UserPromptSubmit: [ - { hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }, + { hooks: [{ type: 'command', command: HOOK_CMD }] }, { hooks: [{ type: 'command', command: 'my-own-hook' }] }, ], }, @@ -1204,16 +1232,27 @@ describe('Installer targets — partial-state idempotency', () => { expect(promptCommands(s)).toEqual(['my-own-hook']); }); + it('claude: removePromptHookEntry removes the other platform\'s spelling too', () => { + const file = seedSettings('global', { + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: OTHER_PLATFORM_HOOK_CMD }] }], + }, + }); + expect(removePromptHookEntry('global').action).toBe('removed'); + const s = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(promptCommands(s)).toEqual([]); + }); + it('claude: removePromptHookEntry leaves the legacy auto-sync hook untouched', () => { const file = seedSettings('global', { hooks: { - UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'codegraph prompt-hook' }] }], + UserPromptSubmit: [{ hooks: [{ type: 'command', command: HOOK_CMD }] }], Stop: [{ hooks: [{ type: 'command', command: 'codegraph sync-if-dirty' }] }], }, }); expect(removePromptHookEntry('global').action).toBe('removed'); const s = JSON.parse(fs.readFileSync(file, 'utf-8')); - expect(promptCommands(s)).not.toContain('codegraph prompt-hook'); + expect(promptCommands(s)).not.toContain(HOOK_CMD); const stopCmds = (s.hooks?.Stop ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); expect(stopCmds).toContain('codegraph sync-if-dirty'); }); diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index bdfe6e6fb..e95b0a35d 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -296,12 +296,24 @@ function isLegacyCodegraphHookCommand(command: unknown): boolean { /** * The front-load prompt-hook command the installer writes into Claude's - * `UserPromptSubmit` (see writePromptHookEntry). Matched by substring so an + * `UserPromptSubmit` (see writePromptHookEntry). On Windows the launcher on + * PATH is `codegraph.cmd`, and Claude Code executes hooks through Git Bash, + * which — unlike cmd.exe — applies no PATHEXT: a bare `codegraph` is + * "command not found", exit 127 (#1466). Write the extension there; the + * `.cmd` spelling also resolves fine under cmd.exe and PowerShell. + */ +const PROMPT_HOOK_COMMAND = process.platform === 'win32' + ? 'codegraph.cmd prompt-hook' + : 'codegraph prompt-hook'; + +/** + * Every spelling the installer has ever written (a settings.json can carry + * the other platform's form across a sync). Matched by substring so an * `npx @colbymchenry/codegraph prompt-hook` form is recognized too. */ -const PROMPT_HOOK_COMMAND = 'codegraph prompt-hook'; +const PROMPT_HOOK_FORMS = ['codegraph prompt-hook', 'codegraph.cmd prompt-hook']; function isPromptHookCommand(command: unknown): boolean { - return typeof command === 'string' && command.includes(PROMPT_HOOK_COMMAND); + return typeof command === 'string' && PROMPT_HOOK_FORMS.some((f) => command.includes(f)); } /** @@ -424,10 +436,31 @@ export function writePromptHookEntry(loc: Location): WriteResult['files'][number } if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = []; + // Self-heal (#1466): a pre-fix install on Windows wrote the bare + // `codegraph prompt-hook`, which Git Bash resolves to nothing; a + // settings.json carried across platforms can hold the other spelling too. + // Rewrite an installer-written command to this platform's form in place. + // Only the exact installer spellings migrate — an `npx …` or hand-edited + // variant is the user's own and stays untouched. + let migrated = false; + for (const group of settings.hooks.UserPromptSubmit) { + if (!group || !Array.isArray(group.hooks)) continue; + for (const h of group.hooks) { + if (h && PROMPT_HOOK_FORMS.includes(h.command) && h.command !== PROMPT_HOOK_COMMAND) { + h.command = PROMPT_HOOK_COMMAND; + migrated = true; + } + } + } + const already = settings.hooks.UserPromptSubmit.some( (g: any) => g && Array.isArray(g.hooks) && g.hooks.some((h: any) => isPromptHookCommand(h?.command)), ); - if (already) return { path: file, action: 'unchanged' }; + if (already) { + if (!migrated) return { path: file, action: 'unchanged' }; + writeJsonFile(file, settings); + return { path: file, action: 'updated' }; + } settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: PROMPT_HOOK_COMMAND }], From 02c0e2c935c0b4cd35537a71643b31ffe26215c7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Fri, 31 Jul 2026 21:38:38 -0500 Subject: [PATCH 07/87] fix(db): stop watchdog-killed sessions from leaking the SQLite WAL without bound (#1431) (#1490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SIGKILL'd process (the #850 liveness watchdog, OOM, a crash) leaves its WAL on disk; the next session appends to the same file; and nothing ever truncated it — PASSIVE checkpoints fold frames but keep the file at its high-water mark, and the one shrinking path (a clean last-connection close) is exactly what a killed-daemon world never takes. Observed at 25.6 GB on a 5.46 GB DB, growing until the disk filled. - journal_size_limit on every connection: resetting checkpoints now clip the WAL back to the cap instead of leaving it at its high-water mark. - healOversizedWal() fired from every DatabaseConnection.open: off-thread PASSIVE fold + TRUNCATE when the leftover WAL exceeds the cap (64 MB, CODEGRAPH_WAL_HEAL_MB to override). Single-flight per connection with bounded retries — concurrent passes defeat each other (each checkpoint sees the other as a busy reader). - Daemon/direct MCP watchdogs now pass progressPaths (DB + WAL), extending the #1231 slow-disk deferral to the long-lived server so a healthy daemon mid slow statement isn't SIGKILL'd — fewer kills, fewer leaked WALs. - codegraph status shows WAL size (human + JSON) and warns when it dwarfs the DB; daemon.log lines and the watchdog kill notice now carry ISO timestamps so kills can be placed in time. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 3 + __tests__/wal-heal.test.ts | 194 +++++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 14 +++ src/db/index.ts | 79 ++++++++++++++ src/db/queries.ts | 1 + src/index.ts | 1 + src/mcp/index.ts | 48 ++++++++- src/mcp/liveness-watchdog.ts | 4 +- src/types.ts | 4 + 9 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 __tests__/wal-heal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1a83b89..951673c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431) +- The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431) +- `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431) - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/wal-heal.test.ts b/__tests__/wal-heal.test.ts new file mode 100644 index 000000000..ae3e6cbd8 --- /dev/null +++ b/__tests__/wal-heal.test.ts @@ -0,0 +1,194 @@ +/** + * Regression tests for #1431: a SIGKILL'd session (the #850 liveness watchdog, + * OOM, a crash) leaves the SQLite WAL on disk; the next session appends to the + * same file; and before the fix NOTHING ever truncated it — PASSIVE + * checkpoints fold frames but keep the file at its high-water mark, and the + * only shrinking path (a clean last-connection close) is exactly what a + * killed-daemon world never takes. Observed in the wild at 25.6 GB on a + * 5.46 GB database, growing until the disk filled. + * + * The fix: `journal_size_limit` on every connection (resetting checkpoints now + * clip the file), plus `healOversizedWal()` fired from every + * `DatabaseConnection.open` (off-thread PASSIVE fold + TRUNCATE when the WAL + * exceeds the threshold). + * + * The killed writer here reproduces the real shape: same open pragmas as + * `configureConnection`, `wal_autocheckpoint = 0` (deferred-checkpoint sync + * mode, #1248), bulk writes, then SIGKILL mid-session with the connection open. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { + DatabaseConnection, + WAL_HEAL_THRESHOLD_BYTES, + resolveWalHealBytes, +} from '../src/db/index'; +import { watchdogProgressPaths, stampLogChunk } from '../src/mcp/index'; + +const MB = 1024 * 1024; + +// Writer child: real codegraph pragmas + deferred checkpointing, grows the WAL +// past the target, prints READY, then idles with the connection open until the +// parent SIGKILLs it (what the liveness watchdog does to a daemon). +const WRITER_SOURCE = ` +const { DatabaseSync } = require('node:sqlite'); +const fs = require('fs'); +const dbPath = process.argv[1]; +const targetBytes = Number(process.argv[2]); +const db = new DatabaseSync(dbPath); +db.exec('PRAGMA busy_timeout = 5000'); +db.exec('PRAGMA journal_mode = WAL'); +db.exec('PRAGMA synchronous = NORMAL'); +db.exec('PRAGMA wal_autocheckpoint = 0'); +db.exec('CREATE TABLE IF NOT EXISTS junk (id INTEGER PRIMARY KEY, blob BLOB)'); +const ins = db.prepare('INSERT INTO junk (blob) VALUES (?)'); +const chunk = Buffer.alloc(256 * 1024, 0xab); +const walSize = () => { try { return fs.statSync(dbPath + '-wal').size; } catch (e) { return 0; } }; +while (walSize() < targetBytes) { + db.exec('BEGIN'); + for (let i = 0; i < 20; i++) ins.run(chunk); + db.exec('COMMIT'); +} +process.stdout.write('READY\\n'); +setInterval(() => {}, 1000); +`; + +async function growWalThenSigkill(dbPath: string, targetBytes: number): Promise { + const child = spawn(process.execPath, ['-e', WRITER_SOURCE, dbPath, String(targetBytes)], { + stdio: ['ignore', 'pipe', 'inherit'], + // Keep the child's cwd off the temp dir (Windows EPERM-on-cleanup quirk). + cwd: os.tmpdir(), + }); + await new Promise((resolve, reject) => { + let out = ''; + child.stdout!.on('data', (d) => { + out += String(d); + if (out.includes('READY')) resolve(); + }); + child.on('exit', (code) => reject(new Error(`writer exited early (code ${code})`))); + setTimeout(() => reject(new Error('timed out growing the WAL')), 90_000); + }); + child.kill('SIGKILL'); + await new Promise((r) => child.on('exit', r)); +} + +describe('WAL heal after killed sessions (#1431)', () => { + let dir: string; + let dbPath: string; + const walSize = (): number => { + try { return fs.statSync(`${dbPath}-wal`).size; } catch { return 0; } + }; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wal-heal-')); + dbPath = path.join(dir, 'codegraph.db'); + DatabaseConnection.initialize(dbPath).close(); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves the heal threshold from the env override, defaulting to 64 MB', () => { + expect(resolveWalHealBytes(undefined)).toBe(64 * MB); + expect(resolveWalHealBytes('')).toBe(64 * MB); + expect(resolveWalHealBytes('nope')).toBe(64 * MB); + expect(resolveWalHealBytes('-3')).toBe(64 * MB); + expect(resolveWalHealBytes('128')).toBe(128 * MB); + }); + + it('sets journal_size_limit on every connection so resetting checkpoints clip the file', () => { + const conn = DatabaseConnection.open(dbPath); + try { + // Private-field peek: journal_size_limit is per-connection, so only this + // connection can report it. + const raw = (conn as unknown as { db: { pragma(q: string, o: { simple: true }): unknown } }).db + .pragma('journal_size_limit', { simple: true }); + expect(Number(raw)).toBe(WAL_HEAL_THRESHOLD_BYTES); + } finally { + conn.close(); + } + }); + + it('leaves healthy small WALs alone', async () => { + const conn = DatabaseConnection.open(dbPath); + try { + const res = await conn.healOversizedWal(); + expect(res.healed).toBe(false); + expect(res.beforeBytes).toBeLessThanOrEqual(WAL_HEAL_THRESHOLD_BYTES); + } finally { + conn.close(); + } + }); + + it('reproduces the ratchet and heals it: killed sessions stack the WAL, open() truncates it', async () => { + // Session 1 killed mid-write: WAL survives the SIGKILL. + await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES / 2); + const afterFirstKill = walSize(); + expect(afterFirstKill).toBeGreaterThanOrEqual(WAL_HEAL_THRESHOLD_BYTES / 2); + + // Session 2 appends to the SAME file — the unbounded ratchet. + await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES + 8 * MB); + const afterSecondKill = walSize(); + expect(afterSecondKill).toBeGreaterThan(afterFirstKill); + expect(afterSecondKill).toBeGreaterThan(WAL_HEAL_THRESHOLD_BYTES); + + // The next session opens the DB: the heal folds + truncates. (open() also + // fires the heal itself, so await an explicit pass rather than asserting + // on the racing return values — the on-disk size is the invariant.) + const conn = DatabaseConnection.open(dbPath); + try { + await conn.healOversizedWal(); + expect(walSize()).toBeLessThan(WAL_HEAL_THRESHOLD_BYTES); + // The folded data is all there. + const rows = (conn as unknown as { db: { prepare(q: string): { get(): { n: number } } } }).db + .prepare('SELECT COUNT(*) AS n FROM junk').get(); + expect(rows.n).toBeGreaterThan(0); + } finally { + conn.close(); + } + }, 180_000); + + it('open() itself kicks off the heal without being asked', async () => { + await growWalThenSigkill(dbPath, WAL_HEAL_THRESHOLD_BYTES + 8 * MB); + expect(walSize()).toBeGreaterThan(WAL_HEAL_THRESHOLD_BYTES); + + const conn = DatabaseConnection.open(dbPath); // fire-and-forget heal + try { + const deadline = Date.now() + 30_000; + while (walSize() > WAL_HEAL_THRESHOLD_BYTES && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 200)); + } + expect(walSize()).toBeLessThanOrEqual(WAL_HEAL_THRESHOLD_BYTES); + } finally { + conn.close(); + } + }, 180_000); +}); + +describe('daemon observability for watchdog kills (#1431)', () => { + it('derives watchdog progressPaths from the project root', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-wd-paths-')); + try { + const { progressPaths } = watchdogProgressPaths(dir); + expect(progressPaths).toHaveLength(2); + expect(progressPaths![0].endsWith(path.join('.codegraph', 'codegraph.db'))).toBe(true); + expect(progressPaths![1]).toBe(`${progressPaths![0]}-wal`); + expect(watchdogProgressPaths(null)).toEqual({}); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('stamps log chunks with an ISO-8601 timestamp', () => { + const iso = /^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] /; + expect(String(stampLogChunk('[CodeGraph daemon] Listening.\n'))).toMatch(iso); + const stamped = stampLogChunk(Buffer.from('bytes\n')); + expect(Buffer.isBuffer(stamped)).toBe(true); + expect(String(stamped)).toMatch(iso); + expect(String(stamped).endsWith('bytes\n')).toBe(true); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index eefb5d907..c6e259374 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -961,6 +961,7 @@ program nodeCount: stats.nodeCount, edgeCount: stats.edgeCount, dbSizeBytes: stats.dbSizeBytes, + walSizeBytes: stats.walSizeBytes, backend, journalMode, nodesByKind: stats.nodesByKind, @@ -1017,6 +1018,19 @@ program console.log(` Nodes: ${formatNumber(stats.nodeCount)}`); console.log(` Edges: ${formatNumber(stats.edgeCount)}`); console.log(` DB Size: ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`); + // Surface the WAL sidecar (#1431): a WAL that dwarfs the DB at rest is + // the killed-session leak — invisible before this line, it only showed + // up as a mysteriously full disk. open() above already kicked off the + // automatic heal for the oversized case. + if (stats.walSizeBytes > 0) { + const { WAL_HEAL_THRESHOLD_BYTES } = await import('../db/index'); + const oversized = stats.walSizeBytes > Math.max(WAL_HEAL_THRESHOLD_BYTES, stats.dbSizeBytes); + const walLabel = `${(stats.walSizeBytes / 1024 / 1024).toFixed(2)} MB`; + console.log(` WAL Size: ${oversized ? chalk.yellow(walLabel) : walLabel}`); + if (oversized) { + warn('The write-ahead log is larger than the database — killed sessions left it behind. It is reclaimed automatically on open; if it persists across runs, another live CodeGraph process is holding it.'); + } + } // Surface the active SQLite backend (node:sqlite — Node's built-in real // SQLite, full WAL + FTS5, no native build). const backendLabel = chalk.green(`node:sqlite ${getGlyphs().dash} built-in (full WAL)`); diff --git a/src/db/index.ts b/src/db/index.ts index 1b7a5883b..4d52b0c6c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -35,6 +35,35 @@ function configureConnection(db: SqliteDatabase): void { db.pragma('cache_size = -64000'); // 64 MB page cache db.pragma('temp_store = MEMORY'); // temp tables in memory db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O + // Without a journal_size_limit the -wal file never shrinks below its + // high-water mark while a connection lives: checkpoints fold frames back but + // leave the file at full size, so one giant deferred-sync WAL stays giant + // forever. With the limit set, any checkpoint that resets the WAL truncates + // the file back down. Killed-process leftovers are handled separately by + // healOversizedWal() at open. (#1431) + db.pragma(`journal_size_limit = ${WAL_HEAL_THRESHOLD_BYTES}`); +} + +/** + * WAL size past which `healOversizedWal` (run at every `open`) checkpoints and + * truncates the file, and to which `journal_size_limit` clips the WAL after any + * resetting checkpoint. A SIGKILL'd process (the #850 liveness watchdog, OOM, + * crash) can leave an arbitrarily large WAL behind — a whole deferred-sync + * run's worth (#1248) — and before #1431 no later session ever shrank it: the + * file just grew, killed session after killed session, until the disk filled + * (25.6 GB observed). 64 MB is far above anything a healthy open ever sees + * (a clean close deletes the WAL) yet small enough to cap the leak. + * Override with `CODEGRAPH_WAL_HEAL_MB` (also feeds `journal_size_limit`). + */ +export const WAL_HEAL_THRESHOLD_BYTES = resolveWalHealBytes(process.env.CODEGRAPH_WAL_HEAL_MB); + +/** Resolve the heal threshold from the env override (MB); invalid ⇒ 64 MB. */ +export function resolveWalHealBytes(envVal: string | undefined): number { + if (envVal !== undefined && envVal !== '') { + const n = Number(envVal); + if (Number.isFinite(n) && n > 0) return Math.floor(n * 1024 * 1024); + } + return 64 * 1024 * 1024; } /** @@ -117,6 +146,10 @@ export class DatabaseConnection { // nodes_fts is stale. Rebuild + recreate so search stays in sync. conn.healBulkNodeLoad(); + // Self-heal a killed session's leftover oversized WAL (#1431) — one + // statSync when healthy, off-thread checkpoint+truncate when not. + void conn.healOversizedWal(); + return conn; } @@ -506,6 +539,52 @@ export class DatabaseConnection { return this.checkpointWal('TRUNCATE'); } + /** + * Shrink a leftover oversized WAL (#1431). A SIGKILL'd session — the #850 + * liveness watchdog, OOM, a crash — leaves its WAL on disk, the next session + * appends to the same file, and (pre-#1431) nothing ever truncated it: + * PASSIVE checkpoints fold frames but keep the file at its high-water mark, + * and the one shrinking path (a clean last-connection close) is exactly what + * the killed world never takes. Unbounded growth until the disk fills. + * + * Called fire-and-forget from every `open()`: cost is one statSync when the + * WAL is small (the overwhelmingly common case). Past the threshold it runs + * the off-thread PASSIVE fold then TRUNCATE — both on worker connections + * with a busy_timeout, so a racing writer degrades this to a no-op that the + * next open retries rather than a stall. + */ + async healOversizedWal(): Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> { + const beforeBytes = this.getWalSizeBytes(); + if (beforeBytes <= WAL_HEAL_THRESHOLD_BYTES) { + return { healed: false, beforeBytes, afterBytes: beforeBytes }; + } + // Single-flight: open() fires this fire-and-forget and callers may also + // invoke it explicitly. Two concurrent passes DEFEAT each other — each + // checkpoint worker sees the other as a busy reader and no-ops — so share + // one in-flight pass instead of racing. + this.walHeal ??= this.runWalHeal(beforeBytes).finally(() => { this.walHeal = null; }); + return this.walHeal; + } + + private walHeal: Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> | null = null; + + private async runWalHeal(beforeBytes: number): Promise<{ healed: boolean; beforeBytes: number; afterBytes: number }> { + // A racing reader/writer (another session healing the same file, a query + // pool warming up) degrades a checkpoint pass to a busy no-op — retry a + // few times before leaving the rest to the next open. + for (let attempt = 0; attempt < 3; attempt++) { + if (attempt > 0) await new Promise((r) => setTimeout(r, 300)); + await this.checkpointWalPassive(); + await this.checkpointWalTruncate(); + if (this.getWalSizeBytes() <= WAL_HEAL_THRESHOLD_BYTES) break; + } + const afterBytes = this.getWalSizeBytes(); + if (process.env.CODEGRAPH_WAL_VALVE_DEBUG) { + console.error(`[wal-heal] oversized WAL at open: ${Math.round(beforeBytes / (1024 * 1024))}MB -> ${Math.round(afterBytes / (1024 * 1024))}MB`); + } + return { healed: afterBytes < beforeBytes, beforeBytes, afterBytes }; + } + private async checkpointWal(mode: 'PASSIVE' | 'TRUNCATE'): Promise<{ busy: number; log: number; checkpointed: number } | null> { if (!this.dbPath || this.dbPath === ':memory:') { try { diff --git a/src/db/queries.ts b/src/db/queries.ts index 16b9d5f91..c5c780f3a 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2462,6 +2462,7 @@ export class QueryBuilder { edgesByKind, filesByLanguage, dbSizeBytes: 0, // Set by caller using DatabaseConnection.getSize() + walSizeBytes: 0, // Set by caller using DatabaseConnection.getWalSizeBytes() lastUpdated: Date.now(), }; } diff --git a/src/index.ts b/src/index.ts index 461ff4797..694e93a3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1220,6 +1220,7 @@ export class CodeGraph { getStats(): GraphStats { const stats = this.queries.getStats(); stats.dbSizeBytes = this.db.getSize(); + stats.walSizeBytes = this.db.getWalSizeBytes(); return stats; } diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 8c50c7a99..c7c59f622 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -103,6 +103,47 @@ function daemonInternalSet(): boolean { return !!raw && raw !== '0' && raw.toLowerCase() !== 'false'; } +/** + * Prefix every `process.stderr.write` chunk with an ISO-8601 timestamp. Called + * once, only when this process becomes the detached daemon — whose stderr is + * appended to `.codegraph/daemon.log`. Before #1431 no log line carried a + * timestamp, so watchdog kills and restarts could be counted but never placed + * in time. (The watchdog child writes its kill notice through its own + * inherited fd 2, bypassing this wrapper — it stamps that line itself.) + */ +export function timestampStderrLines(): void { + const orig = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + return (orig as (...args: unknown[]) => boolean)(stampLogChunk(chunk), ...rest); + }) as typeof process.stderr.write; +} + +/** Prepend `[] ` to a log chunk; unknown chunk types pass through. */ +export function stampLogChunk(chunk: string | Uint8Array): string | Uint8Array { + try { + const stamp = `[${new Date().toISOString()}] `; + if (typeof chunk === 'string') return stamp + chunk; + if (Buffer.isBuffer(chunk)) return Buffer.concat([Buffer.from(stamp), chunk]); + } catch { /* stamping is best-effort; never block the write */ } + return chunk; +} + +/** + * Watchdog `progressPaths` for a server keyed on `root`'s index: the SQLite DB + * + its WAL. With these, the #850 liveness watchdog only kills on heartbeat + * silence when the DB files are NOT advancing — the same slow-disk deferral + * the CLI `index`/`init` path got in #1231. Without it, one >timeout + * synchronous statement on a big DB (multi-GB index behind Windows Defender) + * SIGKILLs a perfectly healthy daemon — and a daemon SIGKILL'd at the end of + * nearly every session is what ratcheted the WAL leak in #1431. A true wedge + * still dies: a wedged loop writes nothing, so the files stay still. + */ +export function watchdogProgressPaths(root: string | null): { progressPaths?: string[] } { + if (!root) return {}; + const dbPath = path.join(getCodeGraphDir(root), 'codegraph.db'); + return { progressPaths: [dbPath, `${dbPath}-wal`] }; +} + /** * Resolve the project root the daemon machinery should key on. Returns * `null` when no `.codegraph/` is reachable from the candidate path — in @@ -346,7 +387,7 @@ export class MCPServer { this.mode = 'direct'; this.installSignalHandlers(); this.installPpidWatchdog(); - this.livenessWatchdog = installMainThreadWatchdog(); + this.livenessWatchdog = installMainThreadWatchdog(watchdogProgressPaths(resolveDaemonRoot(this.projectPath))); } /** @@ -359,6 +400,9 @@ export class MCPServer { * and reaps itself via client-refcount + idle timeout (see {@link Daemon}). */ private async startDaemonProcess(): Promise { + // In daemon mode stderr IS `.codegraph/daemon.log`; stamp every line so + // kills/restarts can be placed in time (#1431 — the log was undatable). + timestampStderrLines(); const root = resolveDaemonRoot(this.projectPath) ?? this.projectPath ?? process.cwd(); for (let attempt = 0; attempt < TAKEOVER_MAX_RETRIES; attempt++) { const lock = tryAcquireDaemonLock(root); @@ -371,7 +415,7 @@ export class MCPServer { // The detached daemon has no PPID watchdog or stdin lifeline, so a // wedged main thread would pin a core forever (#850). The liveness // watchdog is its only recovery path. - this.livenessWatchdog = installMainThreadWatchdog(); + this.livenessWatchdog = installMainThreadWatchdog(watchdogProgressPaths(root)); return; // the net.Server keeps the process alive } diff --git a/src/mcp/liveness-watchdog.ts b/src/mcp/liveness-watchdog.ts index 77aa01139..8e253ffa8 100644 --- a/src/mcp/liveness-watchdog.ts +++ b/src/mcp/liveness-watchdog.ts @@ -113,7 +113,9 @@ const capMs = Number(process.argv[3]); const progressPaths = process.argv.slice(4); const secs = Math.round(timeoutMs / 1000); function kill(extra) { - try { fs.writeSync(2, Buffer.from('[CodeGraph] Main thread unresponsive for ~' + secs + 's' + (extra || '') + ' — killing the wedged process so a fresh one can start (#850). Disable with CODEGRAPH_NO_WATCHDOG=1.\\n')); } catch (e) {} + // Timestamped so daemon.log kills can be correlated with anything (#1431) — + // computed here at kill time; this child process is never the wedged one. + try { fs.writeSync(2, Buffer.from('[' + new Date().toISOString() + '] [CodeGraph] Main thread unresponsive for ~' + secs + 's' + (extra || '') + ' — killing the wedged process so a fresh one can start (#850). Disable with CODEGRAPH_NO_WATCHDOG=1.\\n')); } catch (e) {} try { process.kill(parentPid, 'SIGKILL'); } catch (e) {} process.exit(0); } diff --git a/src/types.ts b/src/types.ts index 5b0e407c5..8cc600b1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -574,6 +574,10 @@ export interface GraphStats { /** Database size in bytes */ dbSizeBytes: number; + /** Size of the SQLite `-wal` sidecar in bytes (0 when absent). A WAL far + * larger than the DB at rest means killed sessions left it behind (#1431). */ + walSizeBytes: number; + /** Last update timestamp */ lastUpdated: number; } From f2a5df34de99c46c70a889fc6c8852c886133134 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 1 Aug 2026 01:12:52 -0500 Subject: [PATCH 08/87] fix(mcp): never serve a mis-sliced symbol body from a file that drifted from its index (#1474) (#1492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codegraph_node / codegraph_explore read CURRENT bytes but slice them at INDEXED line ranges; after an un-synced edit that slice can be a DIFFERENT symbol's code served under the requested name — isError: false, introduced by the 'verbatim … do not Read' guarantee. The watcher-based pending (#403) and degraded (#876) banners cannot cover a project reached via projectPath: cross-project instances have no watcher, by construction. Freshness is now verified at the point of emission from data the index already stores: one stat per rendered file (size + floored mtime, the sync fast path's own test), sha256 content-hash compare only on stat mismatch (so a touch/identical rewrite never false-positives), memoized briefly per handler. On drift: - codegraph_node: small files ship WHOLE and CURRENT (Read-parity, still no Read needed); large ones omit the body with an explicit notice steering to the tool's file-read mode or Read. Location/signature stay, flagged as possibly shifted. - codegraph_explore: the whole-file render (already correct by construction) is kept and flagged; adaptive/skeleton/cluster slicing is disabled for drifted files — a too-big drifted file is omitted with a notice instead. The verbatim/do-not-Read header gains a per-file exception, and a trailing note flags shifted line references (flow, blast radius, symbol lists). The guarantee itself is preserved: everything actually rendered is still byte-accurate — drifted files ship whole or not at all, never as a possibly-wrong slice. A re-sync of the target project restores normal output (covered by test). Adds __setLoadCodeGraphForTests (same seam pattern as __setFsWatchForTests) so in-process tests can exercise a genuine cross-project open, which vitest's transform cannot service through the lazy require. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + __tests__/mcp-stale-slice.test.ts | 221 ++++++++++++++++++++++++++++++ src/mcp/server-instructions.ts | 1 + src/mcp/tools.ts | 198 +++++++++++++++++++++++++- 4 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 __tests__/mcp-stale-slice.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 951673c2c..b36a9ddef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431) - `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431) - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) +- When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/mcp-stale-slice.test.ts b/__tests__/mcp-stale-slice.test.ts new file mode 100644 index 000000000..bc550cc56 --- /dev/null +++ b/__tests__/mcp-stale-slice.test.ts @@ -0,0 +1,221 @@ +/** + * Disk-drift guard on code-slice renders (issue #1474). + * + * codegraph_node / codegraph_explore read CURRENT bytes from disk but slice + * them at INDEXED line ranges. When a file changed after its last index sync, + * that slice is a DIFFERENT symbol's code served under the requested name — + * `isError: false`, introduced by the "verbatim … do not Read" guarantee. The + * watcher-based pending banner (#403) cannot cover a project reached via + * `projectPath` (cross-project instances have no watcher, by construction). + * + * The fix verifies freshness at the point of emission from data the index + * already stores (files.size / modified_at, content_hash on stat mismatch): + * a drifted file is never rendered as a slice — small files ship whole and + * current (Read-parity), large ones are omitted with an explicit notice. + * + * These tests exercise the full real path: real index + real + * ToolHandler.execute(), including the cross-project `projectPath` form the + * issue was filed against. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools'; + +/** ~1,100-line file: handler0…handler79 plus `orchestrate` at the bottom — + * mirrors the issue's fixture. Big enough that explore takes the clustered + * render and codegraph_node's whole-file stale fallback does NOT fit. */ +function bigFileContent(): string { + const parts: string[] = []; + for (let h = 0; h < 80; h++) { + parts.push(`/** handler number ${h} */`); + parts.push(`export function handler${h}(input: string): string {`); + for (let s = 0; s < 8; s++) { + parts.push(` const v${s} = input + "-step${s}-h${h}";`); + } + parts.push(` return v7;`); + parts.push(`}`); + parts.push(''); + } + parts.push(`export function orchestrate(input: string): string {`); + parts.push(` handler0(input);`); + parts.push(` handler1(input);`); + parts.push(` handler2(input);`); + parts.push(` handler3(input);`); + parts.push(` return input;`); + parts.push(`}`); + parts.push(''); + return parts.join('\n'); +} + +/** 45 lines of new helpers inserted at the top — shifts every symbol down. */ +function insertedPrelude(): string { + const parts: string[] = []; + for (let h = 0; h < 4; h++) { + parts.push(`/** inserted helper ${h} */`); + parts.push(`export function insertedHelper${h}(x: number): number {`); + for (let s = 0; s < 7; s++) { + parts.push(` x = x + ${s};`); + } + parts.push(` return x;`); + parts.push(`}`); + } + parts.push(''); + return parts.join('\n') + '\n'; +} + +function getText(result: { content: Array<{ type: string; text?: string }>; isError?: boolean }): string { + return result.content.map((c) => c.text ?? '').join('\n'); +} + +describe('MCP stale-slice guard (#1474)', () => { + let fixtureDir: string; // the project that goes stale + let otherDir: string; // a different indexed project — the server's default + let cgFixture: CodeGraph; + let cgOther: CodeGraph; + let handler: ToolHandler; + + beforeEach(async () => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-slice-fx-')); + otherDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-stale-slice-other-')); + fs.mkdirSync(path.join(fixtureDir, 'src')); + fs.mkdirSync(path.join(otherDir, 'src')); + fs.writeFileSync(path.join(fixtureDir, 'src', 'big.ts'), bigFileContent()); + fs.writeFileSync( + path.join(fixtureDir, 'src', 'small.ts'), + 'export function smallTarget(n: number): number {\n return n * 2;\n}\n', + ); + fs.writeFileSync( + path.join(otherDir, 'src', 'unrelated.ts'), + 'export function unrelated() { return 0; }\n', + ); + + cgFixture = CodeGraph.initSync(fixtureDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cgFixture.indexAll(); + cgOther = CodeGraph.initSync(otherDir, { config: { include: ['**/*.ts'], exclude: [] } }); + await cgOther.indexAll(); + // The issue's exact topology: the server's default project is a DIFFERENT + // project; the stale one is reached via `projectPath` and therefore has no + // watcher — the #403/#876 banners cannot fire for it by construction. + // (The seam services ToolHandler's lazy cross-project require, which + // vitest's module transform can't resolve.) + __setLoadCodeGraphForTests(CodeGraph); + handler = new ToolHandler(cgOther); + }); + + afterEach(() => { + __setLoadCodeGraphForTests(null); + try { handler.closeAll(); } catch { /* ignore */ } + try { cgFixture.close(); } catch { /* ignore */ } + try { cgOther.close(); } catch { /* ignore */ } + for (const dir of [fixtureDir, otherDir]) { + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function shiftBigFile(): void { + const p = path.join(fixtureDir, 'src', 'big.ts'); + fs.writeFileSync(p, insertedPrelude() + fs.readFileSync(p, 'utf-8')); + } + + it('codegraph_node never serves another symbol\'s body from a drifted file (cross-project)', async () => { + shiftBigFile(); + const result = await handler.execute('codegraph_node', { + symbol: 'orchestrate', + includeCode: true, + projectPath: fixtureDir, + }); + const text = getText(result); + expect(result.isError).toBeFalsy(); + // The pre-fix failure: the indexed range now lands in handler76/handler77. + expect(text).not.toContain('-h76'); + expect(text).not.toContain('handler77'); + // The drift is announced and the agent is pointed at trustworthy reads. + expect(text).toContain('changed on disk after it was last indexed'); + expect(text).toContain('orchestrate'); + }); + + it('codegraph_node serves the full CURRENT source of a small drifted file (Read-parity fallback)', async () => { + const p = path.join(fixtureDir, 'src', 'small.ts'); + fs.writeFileSync(p, '/** new first line */\nexport const shift = 1;\n' + fs.readFileSync(p, 'utf-8')); + const result = await handler.execute('codegraph_node', { + symbol: 'smallTarget', + includeCode: true, + projectPath: fixtureDir, + }); + const text = getText(result); + expect(result.isError).toBeFalsy(); + expect(text).toContain('full CURRENT source'); + // Current content, including the just-inserted lines the index knows nothing about. + expect(text).toContain('new first line'); + expect(text).toContain('smallTarget'); + }); + + it('an identical rewrite (mtime churn, same bytes) does not trip the guard', async () => { + const p = path.join(fixtureDir, 'src', 'big.ts'); + fs.writeFileSync(p, fs.readFileSync(p, 'utf-8')); + const result = await handler.execute('codegraph_node', { + symbol: 'orchestrate', + includeCode: true, + projectPath: fixtureDir, + }); + const text = getText(result); + expect(text).not.toContain('changed on disk'); + expect(text).toContain('export function orchestrate'); + }); + + it('codegraph_explore omits (never mis-slices) a big drifted file and flags line refs', async () => { + shiftBigFile(); + const result = await handler.execute('codegraph_explore', { + query: 'orchestrate handler3', + projectPath: fixtureDir, + }); + const text = getText(result); + expect(result.isError).toBeFalsy(); + expect(text).toContain('changed on disk after the last index sync'); + // No sliced body from the drifted file — its step lines must not appear. + expect(text).not.toMatch(/-step\d-h\d/); + // Line-reference caveat for the drifted file. + expect(text).toContain('may be shifted'); + }); + + it('re-syncing the project restores normal output with no drift markers', async () => { + shiftBigFile(); + await cgFixture.sync(); + // Fresh handler: the drift verdict is briefly memoized per handler. + const freshHandler = new ToolHandler(cgOther); + try { + const result = await freshHandler.execute('codegraph_node', { + symbol: 'orchestrate', + includeCode: true, + projectPath: fixtureDir, + }); + const text = getText(result); + expect(text).not.toContain('changed on disk'); + expect(text).toContain('export function orchestrate'); + // Location reflects the post-shift position (45 inserted lines). + expect(text).toMatch(/Location:\*\* src\/big\.ts:\d+/); + } finally { + try { freshHandler.closeAll(); } catch { /* ignore */ } + } + }); + + it('the guard also fires on the default project when no watcher is running', async () => { + shiftBigFile(); + const direct = new ToolHandler(cgFixture); + try { + const result = await direct.execute('codegraph_node', { + symbol: 'orchestrate', + includeCode: true, + }); + const text = getText(result); + expect(text).not.toContain('handler77'); + expect(text).toContain('changed on disk after it was last indexed'); + } finally { + try { direct.closeAll(); } catch { /* ignore */ } + } + }); +}); diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts index 88f6f2e3f..bf3839afe 100644 --- a/src/mcp/server-instructions.ts +++ b/src/mcp/server-instructions.ts @@ -60,6 +60,7 @@ calls; a grep/read exploration is dozens. - **Don't grep or Read first** to find or understand indexed code — ONE \`codegraph_explore\` returns the relevant symbols' source together in a single round-trip. Reach for raw \`Read\`/\`Grep\` only to confirm a specific detail codegraph didn't cover, or for what codegraph doesn't index (configs, docs). - **Don't reconstruct a flow by hand** — name the endpoints in one \`codegraph_explore\` and it surfaces the path between them, dynamic-dispatch hops included. - **After editing, check the staleness banner.** When a tool response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files are pending re-index — Read those specific files for accurate content. Every file NOT in that banner is fresh, so still trust codegraph. A different, rarer banner — "⚠️ CodeGraph auto-sync is DISABLED…" — means live watching stopped entirely (the whole index is frozen, not just a few files); until it's resolved, Read files directly to confirm anything that may have changed. +- **A file flagged "⚠ changed on disk after the last index sync" drifted from its index** (most common on projects queried via \`projectPath\`, which have no live watcher). Codegraph never serves a possibly-mis-sliced body from such a file — it either shows the file's full CURRENT source (trust it as a Read) or omits the source with this flag. When the source was omitted, Read that specific file; line numbers referencing it elsewhere in the response may be shifted until that project's next sync. All unflagged files remain trustworthy. ## Limitations diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b31c64fc7..3995ab3b3 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -13,7 +13,16 @@ import { findNearestCodeGraphRoot } from '../directory'; // CodeGraph is pulled in only when a tool actually opens a project. require() is // sync + cached (CommonJS build). const loadCodeGraph = (): typeof import('../index').default => - (require('../index') as typeof import('../index')).default; + loadCodeGraphForTests ?? (require('../index') as typeof import('../index')).default; +// Test seam (same pattern as the watcher's `__setFsWatchForTests`): vitest's +// module transform can't service the lazy `require('../index')` above, so +// in-process tests that exercise a genuine cross-project open (an explicit +// `projectPath` to a different project — issue #1474's repro shape) inject the +// already-imported class here. Never set outside tests. +let loadCodeGraphForTests: typeof import('../index').default | null = null; +export function __setLoadCodeGraphForTests(cls: typeof import('../index').default | null): void { + loadCodeGraphForTests = cls; +} import { detectWorktreeIndexMismatch, worktreeMismatchWarning, @@ -26,7 +35,9 @@ import { isTestFile, normalizeNameToken } from '../search/query-utils'; import { existsSync, readFileSync, + statSync, } from 'fs'; +import { createHash } from 'crypto'; import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils'; import { isGeneratedFile } from '../extraction/generated-detection'; import { scanDynamicDispatch } from './dynamic-boundaries'; @@ -1257,6 +1268,67 @@ export class ToolHandler { * Cost when nothing is pending — the common case — is one boolean check. * No I/O, no parsing of markdown beyond a per-pending-file substring scan. */ + private driftCache = new Map(); + private static readonly DRIFT_TTL_MS = 2000; + + /** + * On-disk drift check for a single indexed file (issue #1474). The code + * renderers slice CURRENT bytes at INDEXED line ranges; when the file + * changed after its last index sync those ranges can point at a DIFFERENT + * symbol's code — served under the requested name with `isError: false`. + * The watcher-based pending/degraded banners can't cover this for a + * project reached via `projectPath` (cross-project instances have no + * watcher, by construction), so freshness is verified here, at the point + * of emission, from data the index already stores. + * + * Cheap and precise: one stat() per file (size + mtime, the same + * comparison the sync fast path uses); only on a stat mismatch is the + * content hashed (sha256, matching extraction's `hashContent`) so a + * touch/checkout that rewrote identical bytes never false-positives. + * Results are memoized briefly so one response rendering the same file in + * several sections pays for the check once. + * + * Returns true when the on-disk file differs from what was indexed — + * i.e. indexed line ranges for it are NOT trustworthy. Any failure + * (missing files-table row, stat/read error) reports false: those cases + * are handled by the existing not-found paths, and a wrong "stale" flag + * would needlessly push the agent back to Read. + */ + private isFileStaleOnDisk(cg: CodeGraph, relPath: string, content?: string): boolean { + let root: string; + try { + root = cg.getProjectRoot(); + } catch { + return false; + } + const key = `${root}\0${relPath}`; + const now = Date.now(); + const hit = this.driftCache.get(key); + if (hit && now - hit.at < ToolHandler.DRIFT_TTL_MS) return hit.stale; + let stale = false; + try { + const rec = cg.getFile(relPath); + const absPath = rec ? validatePathWithinRoot(root, relPath) : null; + if (rec && absPath && existsSync(absPath)) { + const st = statSync(absPath); + // Same freshness test as the sync fast path (extraction/index.ts): + // equal size + equal floored mtime ⇒ unchanged, no read needed. + if (st.size !== rec.size || Math.floor(st.mtimeMs) !== Math.floor(rec.modifiedAt)) { + const data = content ?? readFileSync(absPath, 'utf-8'); + // Must stay byte-identical to extraction's `hashContent` (sha256 over + // the utf-8 string) — the identical-rewrite test in + // mcp-stale-slice.test.ts pins the parity. Inlined (not imported) + // to keep the extraction module off the MCP startup path. + stale = createHash('sha256').update(data).digest('hex') !== rec.contentHash; + } + } + } catch { + stale = false; + } + this.driftCache.set(key, { at: now, stale }); + return stale; + } + private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult { if (result.isError) return result; @@ -3139,6 +3211,9 @@ export class ToolHandler { lines.push('**Source Code**'); lines.push(''); + // Recorded so the drift pass below (#1474) can append a per-file exception + // to this guarantee after the render loop knows which files drifted. + const verbatimHeaderIdx = lines.length; lines.push('> The code below is the **verbatim, current on-disk source** of these files — re-read from disk on this call and line-numbered, byte-for-byte identical to what the Read tool returns. It is NOT a summary, outline, or stale cache. Treat each block as a Read you have already performed: do not Read a file shown here.'); lines.push(''); @@ -3148,6 +3223,14 @@ export class ToolHandler { // (#1046) — it must reflect what we show, not the raw candidate gather. const renderedFilePaths: string[] = []; let anyFileTrimmed = false; + // Files that changed on disk after their last index sync (#1474). Their + // indexed line ranges are untrustworthy, so sliced renders (adaptive / + // skeleton / clusters) are OFF for them: a small drifted file still ships + // whole (current bytes, correct by construction → staleRendered), a big one + // is omitted with an explicit notice (→ staleOmitted) — honest absence + // instead of a different symbol's code under the requested name. + const staleRendered: string[] = []; + const staleOmitted: string[] = []; for (const [filePath, group] of sortedFiles) { if (filesIncluded >= maxFiles) break; @@ -3174,6 +3257,11 @@ export class ToolHandler { const fileLines = fileContent.split('\n'); const lang = group.nodes[0]?.language || ''; + // Disk-drift gate (#1474): every render branch below except whole-file + // slices fileContent (CURRENT bytes) at INDEXED line ranges. Content is + // already in hand, so the check costs one stat (hash only on mismatch). + const fileStale = this.isFileStaleOnDisk(cg, filePath, fileContent); + // Adaptive sizing (CODEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file // to a per-symbol view when it's a redundant member of a polymorphic family. // Engages iff ALL hold: @@ -3212,7 +3300,7 @@ export class ToolHandler { const onSpineGodFile = hasSpineNode && namedBodyChars > budget.maxCharsPerFile && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id)); - if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0 + if (!fileStale && adaptiveExploreEnabled() && flow.pathNodeIds.size > 0 && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) { const syms = group.nodes .filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0) @@ -3326,7 +3414,11 @@ export class ToolHandler { )]; const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader); const omitted = uniqSymbols.length - headerNames.length; - const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')); + // A drifted file rendered WHOLE is still correct (current bytes, + // numbered from 1) — only the index-derived symbol list / line refs to + // it elsewhere in this response may be shifted (#1474). Flag that. + const staleSuffix = fileStale ? ' · ⚠ changed since last index sync — source below is current; the symbol list may be outdated' : ''; + const wholeHeader = fileSectionHeader(filePath, (omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')) + staleSuffix); if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) { // Don't slice a whole file mid-method: an incidental file that doesn't @@ -3339,6 +3431,22 @@ export class ToolHandler { totalChars += wholeSection.length + 200; renderedFilePaths.push(filePath); filesIncluded++; + if (fileStale) staleRendered.push(filePath); + continue; + } + + // Drifted file too big for the whole-file window (#1474): the cluster / + // skeleton renders below would slice current bytes at indexed ranges — + // on a shifted file that serves a DIFFERENT symbol's code under the + // requested name. Omit the source with an explicit notice instead; + // never render a possibly-wrong slice. + if (fileStale) { + staleOmitted.push(filePath); + lines.push( + fileSectionHeader(filePath, '⚠ changed on disk after the last index sync — source omitted (indexed line ranges no longer match, so a slice could show the wrong code). Read this file directly for current content; the change is picked up on that project\'s next index sync.'), + '', + ); + totalChars += 260; continue; } @@ -3626,6 +3734,22 @@ export class ToolHandler { filesIncluded++; } + // Drift epilogue (#1474). The "verbatim / do not Read" guarantee above + // stays TRUE for everything actually rendered (drifted files ship whole or + // not at all — never as a possibly-wrong slice), but two caveats must be + // explicit: omitted files need Reading, and index-derived LINE REFERENCES + // to any drifted file (flow steps, blast radius, trail) may be shifted. + if (staleOmitted.length > 0) { + lines[verbatimHeaderIdx] += ' (Exception: files flagged "⚠ changed on disk" below drifted from the index after their last sync — their source is omitted rather than risk a mis-sliced block; Read those specific files.)'; + } + const staleAll = [...new Set([...staleOmitted, ...staleRendered])]; + if (staleAll.length > 0) { + lines.push( + '', + `> ⚠ Changed on disk after the last index sync: ${staleAll.join(', ')}. Line numbers referencing ${staleAll.length === 1 ? 'this file' : 'these files'} elsewhere in this response (flow steps, blast radius, symbol lists) may be shifted until that project's next sync re-indexes ${staleAll.length === 1 ? 'it' : 'them'}.`, + ); + } + // The curated header count is computed from the files that SURVIVE the final // truncation (see end of method) — `filesIncluded` can over-count when the // hard ceiling drops trailing sections — so leave a sentinel here and fill it @@ -3994,6 +4118,14 @@ export class ToolHandler { /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */ private async renderNodeSection(cg: CodeGraph, node: Node, includeCode: boolean): Promise { + // Disk-drift gate (issue #1474): the body below is CURRENT bytes sliced at + // INDEXED line ranges. If the file changed since its last index sync, that + // slice can be a DIFFERENT symbol's code served under this node's name — + // confidently wrong, with no watcher banner to catch it on a `projectPath` + // (cross-project) target. Never emit a slice from a drifted file. + if (this.isFileStaleOnDisk(cg, node.filePath)) { + return this.renderStaleNodeSection(cg, node, includeCode); + } let code: string | null = null; let outline: string | null = null; if (includeCode) { @@ -4011,6 +4143,66 @@ export class ToolHandler { return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node); } + // Whole-file fallback caps for a drifted file (#1474): small enough to fit + // codegraph_node's output cap (MAX_OUTPUT_LENGTH) with headroom for the + // header + trail. A file within these bounds is served WHOLE and CURRENT + // (Read-parity, correct by construction) instead of a possibly-wrong slice. + private static readonly STALE_WHOLE_FILE_MAX_LINES = 300; + private static readonly STALE_WHOLE_FILE_MAX_CHARS = 12000; + + /** + * codegraph_node render for a symbol whose file changed on disk after the + * last index sync (issue #1474). The indexed line range is no longer + * trustworthy, so no slice is emitted: a small file gets its full CURRENT + * source (Read-parity — sufficiency preserved, the agent still doesn't need + * Read); a large one gets an explicit notice steering to the tool's own + * file-read mode (or Read) — honest absence instead of confident wrongness. + * Location/signature stay (they're the index's answer) but are flagged as + * possibly shifted. + */ + private renderStaleNodeSection(cg: CodeGraph, node: Node, includeCode: boolean): string { + const lines: string[] = [ + `**${node.name}** (${node.kind})`, + '', + `**Location:** ${node.filePath}${node.startLine ? `:${node.startLine}` : ''} — ⚠ as of the last index sync; the file has changed on disk since, so this line may be shifted`, + ]; + if (node.signature) { + lines.push(`**Signature:** \`${node.signature}\``); + } + lines.push(''); + let embedded = false; + if (includeCode) { + try { + const absPath = validatePathWithinRoot(cg.getProjectRoot(), node.filePath); + if (absPath && existsSync(absPath) && !isConfigLeafNode(node)) { + const content = readFileSync(absPath, 'utf-8'); + const body = content.replace(/\n+$/, ''); + if ( + body.length <= ToolHandler.STALE_WHOLE_FILE_MAX_CHARS && + body.split('\n').length <= ToolHandler.STALE_WHOLE_FILE_MAX_LINES + ) { + lines.push( + `> ⚠ \`${node.filePath}\` changed on disk after it was last indexed, so the indexed line range for this symbol may no longer match. Showing the file's full CURRENT source instead (Read-parity — treat it as already Read):`, + '', + '```' + (node.language || ''), + numberSourceLines(body, 1), + '```', + ); + embedded = true; + } + } + } catch { + /* fall through to the notice */ + } + } + if (!embedded) { + lines.push( + `> ⚠ \`${node.filePath}\` changed on disk after it was last indexed — the indexed line range for this symbol no longer reliably matches, so its body is omitted rather than risk showing a different symbol's code. For current content, call codegraph_node with \`file: "${node.filePath}"\` (no symbol; \`offset\`/\`limit\` narrow it like Read), or Read the file. The change is picked up automatically on that project's next index sync.`, + ); + } + return lines.join('\n') + this.formatTrail(cg, node); + } + /** * Build the "trail" for a symbol: its direct callees (what it calls) and * callers (what calls it), each with file:line — so codegraph_node doubles as From 38580e0b04150ce21d7863a1352879e5a82d929e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 1 Aug 2026 01:36:07 -0500 Subject: [PATCH 09/87] fix(python): bare class references produce references edges to classes (#1478) (#1493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's class-as-value idioms (return SomeClass, x = SomeClass, registry dicts, classes passed as arguments) produced no references edges, so callers/impact on a Django/DRF serializer missed the views that consume it. Three gates dropped them: - return_statement was never dispatched by PYTHON_SPEC (kernel mirrored) - the extraction gate (definedHere) collected function/method names only - resolution accepted function/method targets only (matchFunctionRef + the function_ref import fast path) Capture return_statement for Python (single expression; tuple returns not descended), admit same-file CLASS names to the gate, and accept class targets for Python bare identifiers — scoped to Python so the TS/JS KIND FILTER contract is untouched. The docopt false-positive mechanism behind the function-only rule (lowercase locals vs same-named methods) doesn't transfer: methods stay excluded for bare ids, and the same-file/import gate + unique-or-drop rules still apply. Probed on django-rest-framework (~250 files): 559 new references→class edges, 10/10 sampled genuine (serializer_class = AuthTokenSerializer, the ModelSerializer field-mapping registry, aliases, ctor args, isinstance). EXTRACTION_VERSION 24 → 25. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + __tests__/function-ref.test.ts | 115 ++++++++++++++++++++++++++- codegraph-kernel/src/python.rs | 9 ++- docs/design/function-ref-capture.md | 19 +++-- src/extraction/extraction-version.ts | 2 +- src/extraction/function-ref.ts | 6 ++ src/extraction/tree-sitter.ts | 6 ++ src/resolution/index.ts | 10 ++- src/resolution/name-matcher.ts | 14 +++- 9 files changed, 171 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b36a9ddef..b144480d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The background server's watchdog no longer kills a healthy server that is just waiting on a slow disk: like indexing already does, it now checks whether the database files are still making progress before concluding the process is stuck. Fewer spurious kills also means fewer leftover write-ahead logs. (#1431) - `codegraph status` now shows the write-ahead log's size next to the database size and warns when killed sessions have left it oversized, and every line in the background server's log now carries a timestamp so kills and restarts can be placed in time. (#1431) - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) +- Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478) - When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index 993b68614..fe5016c13 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -11,7 +11,9 @@ * - decoy: an ambiguous cross-file name (no import, ≥2 definitions) → NO edge * - same-file priority: a same-file definition beats a same-named decoy * - kind filter: a class/variable passed as a value never gets a - * function-ref edge + * function-ref edge — except Python, where class-as-value is a core + * idiom and bare ids ALSO resolve to classes (#1478); methods stay + * excluded for bare ids everywhere * - self: a function passing itself → no self-loop * - drain: all resolvable function_ref rows leave unresolved_refs (no * batched-resolver runaway), and re-index is idempotent @@ -744,6 +746,117 @@ describe('Function-as-value capture (#756)', () => { } }); + it('PYTHON CLASSES: return / alias / registry dict / arg positions produce references edges (#1478)', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pycls-')); + fs.writeFileSync( + path.join(tmpDir, 'serializers.py'), + [ + 'class OrgSerializerFull:', + ' pass', + '', + 'class OrgSerializerBrief:', + ' pass', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'views.py'), + [ + 'from serializers import OrgSerializerFull, OrgSerializerBrief', + '', + 'def register(cls):', + ' pass', + '', + 'class OrgViewSet:', + ' def get_serializer_class(self):', + ' if True:', + ' return OrgSerializerFull', + ' return OrgSerializerBrief', + '', + 'SERIALIZER_REGISTRY = {"org": OrgSerializerFull}', + 'register(OrgSerializerBrief)', + ].join('\n') + ); + fs.writeFileSync( + path.join(tmpDir, 'models.py'), + [ + 'class Config:', + ' pass', + '', + 'def make_config_cls():', + ' return Config', + '', + 'ActiveConfig = Config', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + + // The DRF wiring: get_serializer_class → the imported serializer class, + // via `return` — the issue's headline gap. The module-level registry + // dict rides the file node. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerFull'))).toEqual([ + 'get_serializer_class', + 'views.py', + ]); + // Second branch return + a module-level call argument. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'OrgSerializerBrief'))).toEqual([ + 'get_serializer_class', + 'views.py', + ]); + + // Same-file: factory return + module-level alias assignment. + expect(sourceNames(cg, fnRefEdgesInto(cg, 'Config'))).toEqual([ + 'make_config_cls', + 'models.py', + ]); + + // callers() must now surface the view as a consumer of the serializer. + const serializer = cg + .getNodesByName('OrgSerializerFull') + .find((n) => n.kind === 'class')!; + const callers = cg.getCallers(serializer.id); + expect(callers.some((c) => c.node.name === 'get_serializer_class')).toBe(true); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + + it('PYTHON KIND FILTER: bare ids still never resolve to methods; unknown names stay silent', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-pyneg-')); + fs.writeFileSync( + path.join(tmpDir, 'svc.py'), + [ + 'class Svc:', + ' def refresh(self):', + ' pass', + '', + 'def wire(cb):', + ' pass', + '', + 'def setup(refresh):', + // A local/parameter sharing a same-file METHOD name: the gate lets it + // through (methods are in definedHere) but resolution must refuse — + // a bare id can never be a method value in Python. + ' wire(refresh)', + // A name with no matching class/function anywhere: no edge, silently. + ' return unknown_thing', + ].join('\n') + ); + + const cg = CodeGraph.initSync(tmpDir); + try { + await cg.indexAll(); + expect(fnRefEdgesInto(cg, 'refresh')).toHaveLength(0); + expect(fnRefEdgesInto(cg, 'unknown_thing')).toHaveLength(0); + } finally { + cg.destroy(); + tmpDir = undefined; + } + }); + it('DRAIN: resolvable function_ref rows leave unresolved_refs; re-index is stable', async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-fnref-drain-')); fs.writeFileSync( diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index 0bffcfd06..93cb10a9d 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -250,7 +250,9 @@ impl<'t> Walker<'t> { target_id_str: NONE_STR, }); - if kind == "function" || kind == "method" { + // Classes join the fn-ref gate for Python (#1478): class-as-value is + // a first-class idiom (mirrors flushFnRefCandidates' python branch). + if kind == "function" || kind == "method" || kind == "class" { self.defined_fn_names.insert(name.to_string()); } // captureValueRefScope @@ -711,6 +713,11 @@ impl<'t> Walker<'t> { "keyword_argument" => ("value", "value"), "pair" => ("value", "value"), "list" => ("list", ""), + // `return SomeClass` / `return handler` (#1478) — a single + // returned expression is a direct named child ('list' shape); + // tuple returns sit under expression_list and are not descended + // (mirrors PYTHON_SPEC). + "return_statement" => ("list", ""), _ => return, }; if self.stack.is_empty() { diff --git a/docs/design/function-ref-capture.md b/docs/design/function-ref-capture.md index 7c8ef733f..b58b7208e 100644 --- a/docs/design/function-ref-capture.md +++ b/docs/design/function-ref-capture.md @@ -45,7 +45,7 @@ custom `visitNode` hooks like Scala's val/var handler) get a candidates-only | C / ObjC | `argument_list` | `assignment_expression.right` | `initializer_pair.value` | `initializer_list`, `init_declarator.value` | `&fn` (`pointer_expression`), `@selector(...)` (ObjC) | | C++ | **`&` forms only** in args/rhs/varinit | (same — explicit `&` only) | bare ids at FILE scope only | bare ids at FILE scope only | `&fn`, `&Cls::method` (resolved scoped to the class) | | TS / JS (tsx/jsx) | `arguments` | `assignment_expression.right` | `pair.value` | `array`, `variable_declarator.value` | `this.method` (`member_expression`, class-scoped — see rule 3) | -| Python | `argument_list`, `keyword_argument.value` | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) | +| Python | `argument_list`, `keyword_argument.value`, `return_statement` (#1478 — single expression only; tuple returns not descended) | `assignment.right` | `pair.value` | `list` | `self.method` (`attribute`) | | Go | `argument_list` | `assignment_statement` / `short_var_declaration` (`expression_list`) | `keyed_element` | `literal_value`, `var_spec.value` | — | | Rust | `arguments` | `assignment_expression.right` | `field_initializer.value` | `array_expression`, `static_item` / `let_declaration.value` | — | | Java | `argument_list` | `assignment_expression.right` | — | `variable_declarator.value` | `method_reference` (`Cls::m`, `this::m`) — the only form | @@ -76,11 +76,18 @@ custom `visitNode` hooks like Scala's val/var handler) get a candidates-only `arena_ind_prev = arena_ind` (redis/jemalloc) each matched a unique same-named function somewhere and produced wrong edges when `rhs`/`varinit` were ungated. -3. **TS/JS/Python: bare ids resolve to `function` kind only.** A bare - identifier can never be a method value in these languages (methods need a - receiver — `this.m` / `self.m`), so allowing method targets soaked up - locals passed as arguments (`new Set(selectedPointsIndices)`; - docopt.py's `name`/`match` params — excalidraw/fmt A/B findings). +3. **TS/JS/Python: bare ids resolve to `function` kind only — plus `class` + for Python (#1478).** A bare identifier can never be a method value in + these languages (methods need a receiver — `this.m` / `self.m`), so + allowing method targets soaked up locals passed as arguments + (`new Set(selectedPointsIndices)`; docopt.py's `name`/`match` params — + excalidraw/fmt A/B findings). Python bare ids ALSO accept CLASS targets: + class-as-value is a core Python idiom (`return SomeSerializer`, registry + dicts, `admin.site.register(Model, Admin)`) with no type-annotation + recovery path, the gate additionally admits same-file CLASS names for + Python, and the docopt false-positive mechanism (lowercase locals vs + same-named methods) doesn't transfer to exact-name class matches. TS/JS + keep the class exclusion (the KIND FILTER contract). TS/JS `this.X` values are captured as `this.`-PREFIXED candidates and resolved CLASS-SCOPED (`resolveThisMemberFnRef` in `src/resolution/index.ts`): the target must be a function/method whose diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 618a1b1c3..07ccbb964 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 24; +export const EXTRACTION_VERSION = 25; diff --git a/src/extraction/function-ref.ts b/src/extraction/function-ref.ts index 1bae970a4..a7359b77e 100644 --- a/src/extraction/function-ref.ts +++ b/src/extraction/function-ref.ts @@ -194,6 +194,12 @@ const PYTHON_SPEC: FnRefSpec = { ['keyword_argument', { mode: 'value', field: 'value' }], // Thread(target=worker) ['pair', { mode: 'value', field: 'value' }], ['list', { mode: 'list' }], + // `return SomeClass` / `return handler` — factory returns are how DRF + // wires views to serializers (get_serializer_class) and how Python + // factories hand back callables (#1478). A single returned expression is + // a direct named child, so 'list' covers it; tuple returns (`return A, B`) + // sit under an expression_list child and are deliberately not descended. + ['return_statement', { mode: 'list' }], ]), special: new Set(['attribute']), }; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..9e53e62da 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -649,6 +649,12 @@ export class TreeSitterExtractor { const definedHere = new Set(); for (const n of this.nodes) { if (n.kind === 'function' || n.kind === 'method') definedHere.add(n.name); + // Python only (#1478): class-as-value is a first-class idiom (DRF + // get_serializer_class, Meta.model, registry dicts), so same-file CLASS + // names pass the gate too. Other languages keep the function/method + // gate — TS/JS recover class references through type annotations, and + // resolution's kind filter would drop their class candidates anyway. + else if (this.language === 'python' && n.kind === 'class') definedHere.add(n.name); } // Import-binding names only (all binding emitters push kind 'imports'). diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 598e5e479..ef3a5fc23 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -915,7 +915,15 @@ export class ReferenceResolver { const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref); if (viaImport) { const target = this.queries.getNodeById(viaImport.targetNodeId); - if (target && (target.kind === 'function' || target.kind === 'method')) { + if ( + target && + (target.kind === 'function' || + target.kind === 'method' || + // Python (#1478): an imported class used as a value (`return + // OrgSerializerFull`) resolves through its import like any + // callback — mirrors matchFunctionRef's bareClassOk. + (ref.language === 'python' && target.kind === 'class')) + ) { return viaImport; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2a1fe0d82..967f0b19b 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -232,6 +232,16 @@ export function matchFunctionRef( ref.language === 'cpp' || ref.language === 'python' || ref.language === 'php'; + // Python additionally accepts CLASS targets for bare identifiers (#1478): + // class-as-value is a core Python idiom (`return SomeSerializer`, + // `Meta.model = Org`, registry dicts, `admin.site.register(Model, Admin)`) + // and, unlike TS, Python has no type-annotation recovery path. The + // false-positive mechanism behind the function-only rule was lowercase + // locals colliding with same-named METHODS (docopt.py) — a candidate must + // be an exact-name CLASS node here, and the extraction gate (same-file + // class ∪ imports) plus unique-or-drop still apply. Methods stay excluded. + const bareClassOk = ref.language === 'python'; + // Qualified member-pointer (`&Widget::on_click` → "Widget::on_click"): // resolve the member ON THAT SCOPE — exempt from bareFnOnly (the `&Cls::m` // shape is an explicit member reference). Unique-or-drop like everything else. @@ -264,7 +274,9 @@ export function matchFunctionRef( .getNodesByName(ref.referenceName) .filter( (n) => - (n.kind === 'function' || (!bareFnOnly && n.kind === 'method')) && + (n.kind === 'function' || + (!bareFnOnly && n.kind === 'method') || + (bareClassOk && n.kind === 'class')) && sameLanguageFamily(n.language, ref.language) && n.id !== ref.fromNodeId // a function registering itself is not a dependency edge ); From f6ac7b36e6b998b5304878b15e032796ab3a0d01 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 1 Aug 2026 01:50:44 -0500 Subject: [PATCH 10/87] fix(mcp): blast radius follows caller chains before claiming no test coverage (#1475) (#1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "no covering tests found" flag only inspected a symbol's direct callers, so helpers exercised transitively by tests (logDebug runs 1,471x under npm test) were reported untested — wrong for ~40% of flagged symbols per the issue's measurement. The check now BFSes up the caller graph (3 hops, 64-lookup budget per entry) and reports indirect coverage as "tested via callers: ". When nothing is found it claims only what was measured — "no tests found within 3 caller hops", or the weaker "no test calls this directly" if the budget ran out — and drops the warning glyph. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 1 + __tests__/explore-blast-radius.test.ts | 46 +++++++++++++++++++++++-- src/mcp/tools.ts | 47 +++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b144480d9..8dcd9b0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, the Claude Code prompt hook written by `codegraph install` failed with "command not found" when hooks run through Git Bash, which needs the `.cmd` extension to find the launcher. The installer now writes the platform-correct command, and re-running `codegraph install` (or `codegraph upgrade`) repairs an existing install in place. (#1466) - Python classes used as values — `return SomeSerializer` from a factory method, `handler = SomeClass` aliases, registry dicts and lists, and classes passed as arguments — now produce reference edges in the graph. Previously these idioms were invisible, so on Django and Django REST Framework projects, asking for a serializer's callers or the impact of editing it missed the views that actually use it. Re-index after upgrading to pick up the new edges. (#1478) - When a file changed on disk after its last index sync, `codegraph_node` and `codegraph_explore` could return a different symbol's code under the requested name — current file bytes cut at outdated line positions — while presenting it as verbatim, trustworthy source. This hit hardest on projects queried through `projectPath` (for example, sub-projects of a monorepo), which have no live file watcher to flag pending edits. Both tools now verify each file against the index before showing sliced code: an out-of-date file is either shown whole with its full current source, or its code is withheld with a clear "changed on disk" notice — never served as a wrong slice. A fresh re-index restores normal output automatically. Thanks @inth3shadows for the thorough report and verification passes. (#1474) +- The blast-radius section of `codegraph_explore` flagged "no covering tests found" whenever no test called a symbol directly — falsely branding helpers that tests exercise through their callers as untested (about 40% of flagged symbols in a measured sample). The check now follows caller chains up to 3 hops and reports indirect coverage as "tested via callers"; when nothing is found it states exactly what was checked instead of an unconditional warning. Thanks @inth3shadows for measuring the false-positive rate. (#1475) ## [1.5.0] - 2026-07-21 diff --git a/__tests__/explore-blast-radius.test.ts b/__tests__/explore-blast-radius.test.ts index e85b0738e..50ad36225 100644 --- a/__tests__/explore-blast-radius.test.ts +++ b/__tests__/explore-blast-radius.test.ts @@ -40,6 +40,28 @@ describe('codegraph_explore — blast radius', () => { path.join(src, 'leaf.ts'), `export function lonelyLeaf() { return 42; }\n`, ); + // `deepHelper` is only called by production code (`midCaller`), but the + // test file exercises it transitively — 2 caller hops up (#1475). + fs.writeFileSync( + path.join(src, 'util.ts'), + `export function deepHelper() { return 1; }\n`, + ); + fs.writeFileSync( + path.join(src, 'mid.ts'), + `import { deepHelper } from './util';\n` + + `export function midCaller() { return deepHelper(); }\n`, + ); + fs.writeFileSync( + path.join(src, 'mid.test.ts'), + `import { midCaller } from './mid';\n` + + `export function checkMid() { return midCaller(); }\n`, + ); + // `untestedHelper` has a caller but no test anywhere up its caller chain. + fs.writeFileSync( + path.join(src, 'untested.ts'), + `export function untestedHelper() { return 3; }\n` + + `export function untestedCaller() { return untestedHelper(); }\n`, + ); cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } }); await cg.indexAll(); @@ -60,8 +82,28 @@ describe('codegraph_explore — blast radius', () => { expect(text).toMatch(/caller/); // a caller count is reported // It names WHERE (the caller file) — not the caller's source body. expect(text).toContain('feature.ts'); - // Test coverage is surfaced (either the covering test file, or the warning). - expect(text).toMatch(/tests:.*feature\.test\.ts|no covering tests/); + // The direct covering test file is surfaced. + expect(text).toMatch(/tests:.*feature\.test\.ts/); + }); + + it('surfaces tests that cover a symbol transitively through its callers (#1475)', async () => { + const res = await handler.execute('codegraph_explore', { query: 'deepHelper' }); + const text = res.content[0].text; + + // deepHelper's only direct caller is production code, but mid.test.ts sits + // one more hop up — that must NOT read as "no tests". + expect(text).toMatch(/`deepHelper`[^\n]*tested via callers:[^\n]*mid\.test\.ts/); + const line = text.split('\n').find((l: string) => l.startsWith('- `deepHelper`')); + expect(line).not.toMatch(/no tests found|no covering tests/); + }); + + it('states only what was measured when no test exists up the caller chain', async () => { + const res = await handler.execute('codegraph_explore', { query: 'untestedHelper' }); + const text = res.content[0].text; + + // Bounded claim, no warning glyph — the tool verified nothing beyond 3 hops. + expect(text).toMatch(/`untestedHelper`[^\n]*no tests found within 3 caller hops/); + expect(text).not.toContain('⚠️ no covering tests found'); }); it('omits symbols that have no dependents from the blast radius', async () => { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 3995ab3b3..08308aca9 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2473,7 +2473,7 @@ export class ToolHandler { const where = nonTest.length > 0 ? ` in ${shown}${more}` : ''; const tests = testFiles.length > 0 ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}` - : '; ⚠️ no covering tests found'; + : this.indirectTestNote(cg, uniq, rel); entries.push( `- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`, @@ -2489,6 +2489,51 @@ export class ToolHandler { ].join('\n'); } + /** + * Test-coverage note for a blast-radius entry whose DIRECT callers include no + * test file. A helper called only by production code can still be exercised + * by tests further up the caller chain (#1475: 40% of directly-unflagged + * symbols had a test within 2-3 hops), so walk up to 2 more hops before + * claiming anything — and even then claim only what was measured. + */ + private indirectTestNote(cg: CodeGraph, directCallers: Node[], rel: (p: string) => string): string { + const MAX_HOPS = 3; // direct callers are hop 1 + const BUDGET = 64; // getCallers lookups per entry — bounds god-fan-in symbols + const FILE_CAP = 2; + let budget = BUDGET; + const visited = new Set(directCallers.map((n) => n.id)); + let frontier = directCallers; + for (let hop = 2; hop <= MAX_HOPS && frontier.length > 0 && budget > 0; hop++) { + const next: Node[] = []; + const found = new Set(); + for (const node of frontier) { + if (budget-- <= 0) break; + let callers: Array<{ node: Node }> = []; + try { callers = cg.getCallers(node.id) as Array<{ node: Node }>; } catch { continue; } + for (const c of callers) { + const n = c?.node; + if (!n || visited.has(n.id)) continue; + visited.add(n.id); + const f = rel(n.filePath); + if (isTestFile(f)) found.add(f); + else next.push(n); + } + } + if (found.size > 0) { + const files = [...found]; + const shown = files.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', '); + const more = files.length > FILE_CAP ? ` +${files.length - FILE_CAP}` : ''; + return `; tested via callers: ${shown}${more}`; + } + frontier = next; + } + // Budget exhaustion means hops 2-3 weren't fully searched — fall back to + // the weaker claim that IS established by the direct-caller check. + return budget > 0 + ? `; no tests found within ${MAX_HOPS} caller hops` + : '; no test calls this directly'; + } + /** * Graph-connectivity relevance via Random-Walk-with-Restart (personalized * PageRank) from the query's matched SEED nodes over the call/reference graph. From 49c11fc2e0c02170742be8411e66a31af611f4b7 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 1 Aug 2026 16:17:10 -0500 Subject: [PATCH 11/87] Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telemetry): D1 schema + migrations for raw events and daily rollups First step of replacing PostHog with self-hosted telemetry on Cloudflare D1. Creates the codegraph-telemetry database binding and the initial migration; no worker code paths change yet (the ingest write path and the nightly rollup cron land next). Schema is raw events plus daily rollups: `events` holds one row per sanitized event with the envelope broken out into columns and event-specific props as JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the nightly rollups the dashboard reads; `machine_first_seen` and `machine_days` carry the retention cohorts and are never purged. One generic dimension table covers every bar and pie, so a new breakdown is a cron change rather than a migration. The migration is commented as an audit surface, like the rest of this worker — every column, and which dashboard chart each rollup table serves. Three judgment calls worth flagging, all documented in the file: - `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)` indexes. D1 bills a row write per index touched, so a third index on the hot table costs ~97k writes/day, and `(day, event)` is a covering index for plain day-range scans anyway (verified with EXPLAIN QUERY PLAN). - `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and `machine_days` a `prod` flag. The "users by ..." panels and the production-user count are distinct-machine numbers, not event counts, and they are unrecoverable once raw events are purged. - No CHECK constraint on `event`: the worker's allowlist is the source of truth and the write path is fail-silent, so a rejected INSERT would lose data quietly instead of erroring loudly. Volume note in the migration footer: ~30M row writes/month against the 50M included on Workers Paid. Storage is the tighter constraint — raw events grow ~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180, which would exceed D1's 10 GB per-database cap. * feat(telemetry): admin dashboard worker — scaffold + shared-password auth New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and bound read-only to the same D1 database. Serves a static frontend plus a JSON API behind a shared password, on stats.getcodegraph.com. Auth is the simplest thing that is actually safe for exactly two users: one password in a secret, compared in constant time over SHA-256 digests, and an HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year expiry so you sign in once per browser. The cookie is a signed assertion, not a lookup key — no session store. Its payload carries a fingerprint of the password it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login attempts are capped at 5/min per IP via a ratelimit binding. Everything is deny-by-default: assets.run_worker_first routes every request through the worker before the static-asset server sees it, so the dashboard HTML, its JS, its CSS and the chart library are all behind the session check. The login page is rendered inline by the worker rather than served from public/, which leaves no "is this file public?" judgement calls in the asset directory. Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing secret fails closed rather than opening the dashboard. scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway `wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/ truncated cookies, open-redirect refusal, brute-force capping, and password rotation invalidating live sessions. Refs CG-11. Co-Authored-By: Claude Opus 5 * chore(telemetry-dashboard): simplify the chart-library probe in the shell Refs CG-11. Co-Authored-By: Claude Opus 5 * feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10) Adds a scheduled() handler to the ingest worker that recomputes daily_event_counts / daily_dim_counts / daily_machines for the just-completed UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw events past the retention window. Rollup writes are idempotent upserts, so a re-run never double-counts. Also adds an ADMIN_TOKEN-guarded POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog forwarding path. Co-Authored-By: Claude Opus 5 * feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13) Replaces the scaffold page with the dashboard proper: 19 panels covering every view of the PostHog dashboard this retires, driven by one filter row. src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries, breakdown,activation,retention}, all range-scoped, all parameterized against a closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend does no arithmetic. Rollups answer everything except the activation funnel, which needs raw events and says where they start. The frontend splits into a DOM-free panel registry (public/panels.js) and the page that mounts it (public/app.js), so the render check can drive the same registry the browser rendered from. Panels fail alone, refetch dims rather than flashing, and every chart carries a table twin. Two numbers are labelled rather than rounded off: range-wide "users" per dimension is machine-days (the rollups cannot give distinct machines, and per-day counts are taken as the largest single-event count so one machine's install + index + usage is not counted three times), and recent activation and retention cohorts are marked as still-converting instead of drawn as a cliff. Both colour scales were run through the data-viz validator against the panel surface, not picked by eye; the results are recorded in public/theme.js. Verification, all against the committed fixture (12 machines over 10 days, every expected number worked out by hand from the events, not recorded from a run): scripts/smoke-api.sh 98 assertions scripts/render-check.mjs 79 assertions — real Chromium over CDP, no new deps scripts/smoke-auth.sh 54 assertions (unchanged, still green) Co-Authored-By: Claude Opus 5 * feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14) The account-level steps of the PostHog cutover are the maintainer's to run, so this lands the runbook they follow and the check that has to pass first. The runbook (telemetry-worker/README.md) walks the six steps in the order that keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the subscription. Step 3 records the outgoing version id because `wrangler rollback` is the escape hatch for the whole verification window, and that window is precisely why the PostHog key is deleted last rather than first. The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one seam nothing else did. Both workers declare the same D1 database_id, so pointing them at a single --persist-to directory runs the real chain: a client batch → the ingest worker → D1 → the nightly rollup → the dashboard API reading the numbers back. Every other suite stops at one link — smoke-ingest at the events table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture that the cron never touched. That left the dimension names the rollup WRITES versus the ones the dashboard READS agreeing by convention across two branches, where a mismatch is silent: no error, no failed request, just a panel reading zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a ci machine that is active but not a production user, usage_rollup counts that must be summed rather than tallied, and an uninstall's `targets` that must not leak into the install-scoped breakdown. Writing it caught that the activation funnel's denominator is first-seen machines, not install events (deliberate — a reinstall must not re-enter the funnel), so the suite now pins that distinction rather than assuming it. Also rewords the last PostHog reference in dashboard code: a comment justifying the 14-day retention curve by pointing at a dashboard step 6 deletes. The reasoning now stands on its own. Co-Authored-By: Claude Opus 5 * docs(telemetry): tell the truth about where events are stored (CG-15) The telemetry docs are a privacy contract, and they still described a managed analytics store that no longer receives anything. Replace that with what actually happens now — events land in our own D1 database on Cloudflare, the endpoint makes no outbound requests, raw events are purged after 90 days and only anonymous daily rollups outlive them. This strengthens the guarantee rather than restating it: there is no second party to share with. - TELEMETRY.md: new "Where it is stored" section; the never-collected IP bullet no longer leans on a vendor-side setting to hold. - docs/design/telemetry.md: ingest section rewritten around D1 + the nightly rollup/retention cron; volume math redone on Workers Paid and the D1 quota (storage, not writes, is what sets the 90-day window); new section documenting the dashboard worker and cross-linking it. - Fixed three drifts from the worker allowlist the sweep surfaced: schema_version was still 1, client_name/client_version was still marked "plumbing to add" though session.ts passes it today, and the legacy sqlite_backend field the worker still accepts was undocumented. - telemetry-worker/README.md: step 6 claimed a repo-wide grep came back clean, which this runbook itself falsifies. Added step 7 — deleting the runbook is what makes that grep true, and is the completion check. - smoke-cutover.sh: the vendor guarantee is now asserted by class (no analytics-ingest endpoint referenced) rather than by one vendor's name, so it keeps working once the name is gone. Verified it still catches a planted forwarding URL. 61/61 pass. Retention is documented as 90 days, not the 180 in the task notes: 180 days of raw events exceeds D1's 10 GB per-database cap, and the code purges at 90. Co-Authored-By: Claude Opus 5 * chore: untrack local Kommandr issue DB and ignore its sqlite artifacts Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 --- .gitignore | 3 + .kommandr/kommandr.db | Bin 4096 -> 0 bytes CHANGELOG.md | 4 + TELEMETRY.md | 33 +- docs/design/telemetry.md | 108 +- telemetry-dashboard/.dev.vars.example | 7 + telemetry-dashboard/.gitignore | 7 + telemetry-dashboard/README.md | 193 ++ telemetry-dashboard/package-lock.json | 1577 +++++++++++++++++ telemetry-dashboard/package.json | 22 + telemetry-dashboard/public/app.js | 395 +++++ telemetry-dashboard/public/index.html | 52 + telemetry-dashboard/public/panels.js | 534 ++++++ telemetry-dashboard/public/styles.css | 345 ++++ telemetry-dashboard/public/theme.js | 195 ++ telemetry-dashboard/scripts/fixture.sql | 208 +++ telemetry-dashboard/scripts/render-check.mjs | 465 +++++ telemetry-dashboard/scripts/seed-fixture.sh | 31 + telemetry-dashboard/scripts/smoke-api.sh | 264 +++ telemetry-dashboard/scripts/smoke-auth.sh | 211 +++ telemetry-dashboard/scripts/vendor-assets.mjs | 37 + telemetry-dashboard/src/api.ts | 827 +++++++++ telemetry-dashboard/src/auth.ts | Bin 0 -> 6473 bytes telemetry-dashboard/src/index.ts | 275 +++ telemetry-dashboard/src/login-page.ts | 120 ++ telemetry-dashboard/tsconfig.json | 17 + telemetry-dashboard/wrangler.jsonc | 51 + telemetry-worker/.dev.vars.example | 9 +- telemetry-worker/.gitignore | 3 +- telemetry-worker/README.md | 214 ++- telemetry-worker/migrations/0001_init.sql | 205 +++ telemetry-worker/package.json | 9 +- telemetry-worker/scripts/smoke-cutover.sh | 270 +++ telemetry-worker/scripts/smoke-ingest.sh | 187 ++ telemetry-worker/scripts/smoke-rollup.sh | 276 +++ telemetry-worker/src/env.d.ts | 10 + telemetry-worker/src/index.ts | 173 +- telemetry-worker/src/rollup.ts | 397 +++++ telemetry-worker/wrangler.jsonc | 37 +- 39 files changed, 7683 insertions(+), 88 deletions(-) delete mode 100644 .kommandr/kommandr.db create mode 100644 telemetry-dashboard/.dev.vars.example create mode 100644 telemetry-dashboard/.gitignore create mode 100644 telemetry-dashboard/README.md create mode 100644 telemetry-dashboard/package-lock.json create mode 100644 telemetry-dashboard/package.json create mode 100644 telemetry-dashboard/public/app.js create mode 100644 telemetry-dashboard/public/index.html create mode 100644 telemetry-dashboard/public/panels.js create mode 100644 telemetry-dashboard/public/styles.css create mode 100644 telemetry-dashboard/public/theme.js create mode 100644 telemetry-dashboard/scripts/fixture.sql create mode 100644 telemetry-dashboard/scripts/render-check.mjs create mode 100755 telemetry-dashboard/scripts/seed-fixture.sh create mode 100755 telemetry-dashboard/scripts/smoke-api.sh create mode 100755 telemetry-dashboard/scripts/smoke-auth.sh create mode 100644 telemetry-dashboard/scripts/vendor-assets.mjs create mode 100644 telemetry-dashboard/src/api.ts create mode 100644 telemetry-dashboard/src/auth.ts create mode 100644 telemetry-dashboard/src/index.ts create mode 100644 telemetry-dashboard/src/login-page.ts create mode 100644 telemetry-dashboard/tsconfig.json create mode 100644 telemetry-dashboard/wrangler.jsonc create mode 100644 telemetry-worker/migrations/0001_init.sql create mode 100755 telemetry-worker/scripts/smoke-cutover.sh create mode 100755 telemetry-worker/scripts/smoke-ingest.sh create mode 100755 telemetry-worker/scripts/smoke-rollup.sh create mode 100644 telemetry-worker/src/env.d.ts create mode 100644 telemetry-worker/src/rollup.ts diff --git a/.gitignore b/.gitignore index 9bb977905..47d16c06c 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ __tests__/zz-scratch* # linux-arm64 kernel cross-build cache (rust:1-bookworm builder) target-linux/ +.kommandr/kommandr.db +.kommandr/kommandr.db-wal +.kommandr/kommandr.db-shm diff --git a/.kommandr/kommandr.db b/.kommandr/kommandr.db deleted file mode 100644 index db7a7459cf0d014b0dc2333abb5541c58c2d6ed1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYBBV;st3JAlr;ljiVtj n8UmvsFd71*Aut*OqaiRF0;3@?8UmvsFd71*Aut*O6ovo*g{}s{ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcd9b0ec..7b57b2000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list. + ### Fixes - A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431) diff --git a/TELEMETRY.md b/TELEMETRY.md index f9301da94..c24ebef24 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -70,8 +70,8 @@ per-call event stream, and nothing is sent in real time. - **No source code.** No file paths, file names, directory names, repository names or URLs, symbol names, search queries, or anything else derived from the contents of an indexed project. -- **No IP addresses.** The ingest endpoint never reads, logs, or forwards the client IP, - and IP discarding is enabled at the analytics backend on top of that. No geolocation. +- **No IP addresses.** The ingest endpoint never reads, logs, or stores the client IP — + and there is no analytics vendor downstream that could. No geolocation. - **No fingerprinting.** The machine ID is a random UUID stored in `~/.codegraph/telemetry.json` — delete that file (or run `codegraph telemetry off`, then `on`) and the old ID is gone forever, with no way to reconnect it. @@ -81,12 +81,29 @@ per-call event stream, and nothing is sent in real time. Events POST to `telemetry.getcodegraph.com` — a first-party endpoint whose complete source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It validates -every event and property against the allowlist above (anything else is dropped), strips -IPs, rate-limits, and forwards to a managed analytics store (PostHog, US region) as -anonymous events. Sends are fire-and-forget with a short timeout: offline or air-gapped -machines buffer a bounded local file (256 KB cap) and never retry-loop, log errors, or -slow a command down. Telemetry never adds latency to MCP tool calls — recording is an -in-memory counter. +every event and property against the allowlist above (anything else is dropped), never +reads the client IP, and rate-limits per machine ID. Sends are fire-and-forget with a +short timeout: offline or air-gapped machines buffer a bounded local file (256 KB cap) +and never retry-loop, log errors, or slow a command down. Telemetry never adds latency to +MCP tool calls — recording is an in-memory counter. + +## Where it is stored + +Accepted events are written to **our own database on Cloudflare** (D1) and go nowhere +else. **No third-party analytics vendor receives any of this data**, because the ingest +endpoint makes no outbound requests at all — its source is the entire path your events +take, and there is nothing after it. This is a stronger guarantee than a promise not to +share: there is no second party to share with. + +What is kept is checkable rather than asserted. The storage schema — +[`telemetry-worker/migrations/0001_init.sql`](telemetry-worker/migrations/0001_init.sql), +checked in beside the endpoint that writes it — is the complete list of what a row can +hold, with a comment on every column. + +Individual events are **deleted after 90 days**. What outlives them is anonymous daily +totals: counts per day of things like operating system, version, and language, plus which +days each machine ID was active so returning-user numbers survive. No event details, and +still nothing that identifies a person or a codebase. The engineering contract behind all of this — including the rule that schema changes must update this page, the client, and the public endpoint in one PR — is in diff --git a/docs/design/telemetry.md b/docs/design/telemetry.md index c0263e7e4..4af2b5885 100644 --- a/docs/design/telemetry.md +++ b/docs/design/telemetry.md @@ -1,8 +1,9 @@ # Anonymous usage telemetry -Status: implemented — ingest Worker (`telemetry-worker/`), client (`src/telemetry/`), -`codegraph telemetry` CLI, MCP + installer wiring, `TELEMETRY.md`. Pending: Worker deploy -+ DNS, release. +Status: implemented — client (`src/telemetry/`), `codegraph telemetry` CLI, MCP + installer +wiring, `TELEMETRY.md`, ingest Worker (`telemetry-worker/`) storing to its own Cloudflare D1 +database, nightly rollup + retention cron, and the admin dashboard Worker +(`telemetry-dashboard/`). Scope: public `codegraph` engine (CLI + MCP server + installer) CodeGraph is a local-first tool whose whole pitch is "your code never leaves your machine." @@ -26,7 +27,10 @@ Answer, in aggregate and anonymously: - **No source code, ever.** No file paths, file names, repo names, symbol names, query strings, search terms, or anything derived from the contents of an indexed project. -- No IP addresses (stripped at the edge; storage disabled at the backend too). +- No IP addresses — never read at the edge, and there is no downstream backend that could + see one. +- No third-party analytics vendor. Events are stored only in our own database; the ingest + Worker makes no outbound requests at all. - No hardware fingerprinting — the machine ID is a random UUID, not derived from anything. - No per-keystroke / per-call event stream — usage is aggregated locally into daily rollups before anything is sent. @@ -58,7 +62,7 @@ Common envelope on every batch (computed once per process): | `os` / `arch` | `darwin` / `arm64` | `process.platform` / `process.arch` | | `node_major` | `22` | major only | | `ci` | `false` | `CI` env var present | -| `schema_version` | `1` | bump when the schema changes | +| `schema_version` | `2` | bump when the schema changes (v2 dropped `index.sqlite_backend`) | Event types: @@ -70,8 +74,8 @@ Event types: - **`usage_rollup`** — the workhorse. One event per `(day, kind, name)` per machine, aggregated locally. Props: `kind` (`mcp_tool`/`cli_command`), `name` (e.g. `codegraph_explore`, `affected`), `count`, `error_count`, and for MCP: - `client_name`/`client_version` from the `initialize` handshake (`src/mcp/session.ts` - `case 'initialize'` — plumbing to add; currently unread). + `client_name`/`client_version` captured from the `initialize` handshake + (`src/mcp/session.ts`) and passed through on every `recordUsage` call. The prompt hook additionally rolls up its gate DECISION as `cli_command` counters named `prompt-hook-gate-`, outcome ∈ `high-keyword` / `high-token` / `medium-segment` / `nudge-projects` / `noop-shape` / @@ -87,13 +91,23 @@ Event types: rather than polluting `noop-unverified` (#1142). - **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`. -Volume math: rollups mean monthly events ≈ active machines × active days × distinct -tools used (single digits) — the PostHog free tier (1M events/mo) covers tens of -thousands of MAU. There is no per-call event by design. +One legacy field is still *accepted* and belongs in the mirror even though nothing sends +it: `sqlite_backend` (`native`/`wasm`) on `install` and `index`. Pre-schema-v2 clients +(≤ June 2026) sent it; `node:sqlite` is the only backend now, so current clients omit it. +It is never `required`, and it is safe to drop from the Worker once those clients' +share is negligible. -Events are sent as PostHog **anonymous events** (`$process_person_profile: false`): -cheaper, no person profiles, unique-machine counts still work on `distinct_id` = -`machine_id`. Revisit only if retention tooling demands profiles. +Volume math: rollups mean monthly events ≈ active machines × active days × distinct tools +used (single digits) — there is no per-call event by design. At ~97k accepted POSTs/day +that is ≈30M D1 row writes/month against the 50M included on **Workers Paid**, roughly +doubling to ≈48M once the retention purge reaches steady state (a delete bills like an +insert). Storage is the binding constraint, not writes: raw events grow ≈74 MB/day, so the +90-day window lands at ≈6.7 GB against D1's 10 GB per-database cap — which is what sets the +window. Full arithmetic and the remaining levers are in the migration's footer comment. + +There are no person profiles to opt out of: `machine_id` is the only identifier that exists +anywhere in the system, it is a client-minted random UUID, and unique-machine counts are +computed from it directly in SQL. ## Consent & controls @@ -166,15 +180,59 @@ public on purpose, so anyone can audit exactly what the endpoint stores. It ship with the npm package (excluded by the `files` allowlist): - `POST /v1/events`: validate against the event/property allowlist (drop unknown events, - strip unknown props), enforce sane sizes, **never forward or log the client IP** - (drop `CF-Connecting-IP`), light per-`machine_id` rate limit so abuse can't burn the - ingest cap, forward to `https://us.i.posthog.com/batch/` with the project key from a - Worker secret. Responds `204` on accept (including events dropped by the allowlist) - and honest `4xx` for malformed/oversized/rate-limited requests — the client treats - every response as final and never retries. -- Backend today: PostHog Cloud US, free plan, "discard client IP" enabled, GeoIP disabled, - autocapture/replay/heatmaps/web-vitals all off. The Worker is the seam: swapping the - backend later is a Worker change, not a client release. + strip unknown props), enforce sane sizes, **never read or log the client IP**, light + per-`machine_id` rate limit so abuse can't burn the ingest cap, then write the survivors + to D1. Responds `204` on accept (including events dropped by the allowlist) and honest + `4xx` for malformed/oversized/rate-limited requests — the client treats every response + as final and never retries. +- **Storage: our own Cloudflare D1 database** (`codegraph-telemetry`, bound as `env.DB`). + The Worker makes **no outbound requests** — nothing is forwarded to a third-party + analytics vendor, so there is no vendor-side privacy setting to get wrong and no second + copy of the data anywhere. The complete stored schema is + [`telemetry-worker/migrations/0001_init.sql`](../../telemetry-worker/migrations/0001_init.sql), + checked in for the same reason the Worker's source is public. +- The write is off the response path (`ctx.waitUntil`, one `batch()` = one transaction) and + deliberately **fail-silent**: a D1 error is logged as counts only, never the payload, and + the client still gets its `204`. Clients never retry, so losing a datapoint beats losing + availability. +- **Nightly cron (00:30 UTC, `src/rollup.ts`)** rolls each finished day into anonymous daily + counts (`daily_machines`, `daily_event_counts`, `daily_dim_counts`) and re-runs the two + days before it, since offline clients ship completed-day rollups late. Aggregation is + `INSERT … SELECT … ON CONFLICT DO UPDATE` inside D1 — no event row crosses the wire, and + re-running a day is a no-op rather than a double count. The same job **purges raw + `events` older than `RETENTION_DAYS`** (90; a var in `wrangler.jsonc`). Rollups and + `machine_days`/`machine_first_seen` are kept forever, so shortening the window costs + ad-hoc drill-back, never a chart. +- The Worker remains the seam: changing storage later is a Worker change, not a client + release. The client only ever knows the domain. + +Operational detail — deploy, migrations, the cron, the `POST /admin/rollup` backfill hatch, +and the D1 quota arithmetic — lives in +[`telemetry-worker/README.md`](../../telemetry-worker/README.md). + +## Admin dashboard (Cloudflare Worker) + +`stats.getcodegraph.com` → a second Worker at +[`telemetry-dashboard/`](../../telemetry-dashboard/) — the read side, and the reason +self-hosting the data costs us no analysis capability. Also public source, for the same +reason: the code that touches telemetry should be readable by the people it collects from. +Full documentation is [`telemetry-dashboard/README.md`](../../telemetry-dashboard/README.md). + +- **Same D1 database, read-only.** It never migrates and never writes; schema changes belong + to the ingest Worker. The two Workers are separate deployments that agree on a list of + dimension names by convention alone, which is exactly the seam + `telemetry-worker/scripts/smoke-cutover.sh` exists to cover — a mismatch there is silent, + showing up as a panel that reads zero forever rather than as an error. +- **Reads rollups, not raw events**, so a chart stays correct for days whose raw rows have + been purged. `/api/activation` is the one exception — "did this machine ever run an index" + is not a daily aggregate — so it reads raw `events` and is bounded by the retention window, + which it reports as `raw_events_from`. +- **Auth is a shared password and a signed cookie**, sized for exactly two people: + `ADMIN_PASSWORD` + `SESSION_SECRET` as Worker secrets, constant-time compare, HMAC-signed + cookie with no session store, everything except `/login` and `robots.txt` gated. Rotating + the password signs everyone out; that is the revocation story. +- This Worker *does* read the client IP, solely as a login rate-limit key, never stored or + logged — the one deliberate difference from the ingest Worker, which never reads it at all. ## codegraph-pro rule (do not lose this in upstream merges) @@ -187,9 +245,9 @@ CLAUDE.md and must survive every upstream merge. ## Rollout 1. This doc + repo-root `TELEMETRY.md` (user-facing field-by-field list) + README section. -2. Worker + DNS live first (so the first shipping client never 404s), PostHog dashboards: - weekly active machines, installs by target, usage by tool × client, version adoption, - languages indexed. +2. Worker + DNS live first (so the first shipping client never 404s), then the dashboard + Worker over the same D1: weekly active machines, installs by target, usage by + tool × client, version adoption, languages indexed. 3. Client module + config + `codegraph telemetry` subcommand + MCP `clientInfo` plumbing. 4. Installer toggle + first-run notice. CHANGELOG entry under `[Unreleased]` announcing telemetry, the default, and every off-switch. Release. diff --git a/telemetry-dashboard/.dev.vars.example b/telemetry-dashboard/.dev.vars.example new file mode 100644 index 000000000..1bb2f4bdf --- /dev/null +++ b/telemetry-dashboard/.dev.vars.example @@ -0,0 +1,7 @@ +# Copy to .dev.vars for local development (`npm run dev`) and so that +# `wrangler types` includes both secrets in the generated Env. +# The real values live only in the deployed secrets: +# wrangler secret put ADMIN_PASSWORD +# wrangler secret put SESSION_SECRET +ADMIN_PASSWORD="dev-password" +SESSION_SECRET="dev-session-secret-not-the-real-one" diff --git a/telemetry-dashboard/.gitignore b/telemetry-dashboard/.gitignore new file mode 100644 index 000000000..8dd024eb0 --- /dev/null +++ b/telemetry-dashboard/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.wrangler/ +.dev.vars +# generated by `wrangler types` (npm run types) — includes .dev.vars keys +worker-configuration.d.ts +# copied out of node_modules by `npm run vendor` +public/vendor/ diff --git a/telemetry-dashboard/README.md b/telemetry-dashboard/README.md new file mode 100644 index 000000000..83d673e7c --- /dev/null +++ b/telemetry-dashboard/README.md @@ -0,0 +1,193 @@ +# codegraph telemetry dashboard + +The private admin view behind `stats.getcodegraph.com`. Its sibling +[`telemetry-worker/`](../telemetry-worker/) writes anonymous usage events into a D1 database; +this worker reads them back and draws the charts. Two people use it, so the auth is +deliberately the simplest thing that is actually safe: one shared password in a secret, and +a long-lived signed cookie. + +This directory is in the public repo for the same reason the ingest worker is — the code +that touches telemetry should be readable by the people it collects from. Nothing secret +lives here: the password and the cookie-signing key are deployment secrets, and the D1 +database ID is an identifier, not a credential. + +## What is gated + +Everything except the login page and `robots.txt`. `assets.run_worker_first` is `true` in +`wrangler.jsonc`, so Cloudflare hands *every* request to `src/index.ts` before the static +asset server sees it — the dashboard HTML, its JS, its CSS and the chart library are all +behind the session check, and a request without a valid cookie gets a redirect (pages) or a +`401` (`/api/*`). The login page is rendered inline by the worker rather than served from +`public/`, so the asset directory needs no "is this file public?" judgement calls. + +| Route | Auth | Notes | +|---|---|---| +| `GET /login` | public | Password form. Redirects to `/` if already signed in. | +| `POST /login` | public | Rate-limited per IP; sets the session cookie on success. | +| `POST /logout` | public | Clears the cookie. | +| `GET /robots.txt` | public | `Disallow: /`. | +| `GET /api/*` | required | JSON. `401` without a session. See the API below. | +| everything else | required | Static assets from `public/`. `302 /login` without a session. | + +## The API + +Every endpoint is `GET`, session-gated, and scoped by `?from=YYYY-MM-DD&to=YYYY-MM-DD` +(inclusive, UTC days). Ranges wider than 366 days are clamped and say so in +`range.clamped`. Responses come back Chart.js-shaped — `labels[] + datasets[]` — plus a +`rows[]` in the data's natural shape, which is what each panel's "Show numbers" table +renders. Bad input is a `400` with a message, never a guess. Chart data carries +`Cache-Control: private, max-age=300`. + +| Endpoint | Answers | +|---|---| +| `/api/meta` | The days data actually exists for. The picker anchors its presets on `latest_day` so no chart ends on a day the nightly rollup has not written yet. | +| `/api/summary` | Big numbers: production users, active machines, new machines, installs, uninstalls, indexing runs, tool calls. | +| `/api/timeseries?metric=` | `installs_uninstalls`, `new_installs`, `production_users`, `indexing_activity`, `tool_calls`, `duration_buckets`. One dense point per day — a day with nothing is a zero, not a gap. | +| `/api/breakdown?dim=` | `os`, `arch`, `codegraph_version`, `node_major`, `language`, `file_count_bucket`, `duration_bucket`, `target`, `scope`, `kind`, `name`, `client_name`, `name_error`. Optional `&event=`, `&metric=count\|machines`, `&limit=`. | +| `/api/activation?window=7` | Install → first index funnel, plus the daily rate. | +| `/api/retention` | Day 0–14 cohort curve for machines first seen in the range. | +| `/api/health` | Liveness plus the latest event/rollup day. Uncached. | + +Everything reads the `daily_*` rollups and `machine_days`, which are kept forever, so a +chart stays correct for days whose raw events have been purged. `/api/activation` is the +one exception — "did this machine ever run an index" is not a daily aggregate — so it +reads raw `events` and is bounded by the ingest worker's retention window. It reports +`raw_events_from` for that reason. + +### Two numbers that are easy to misread + +Both are labelled honestly in the UI rather than rounded off into something friendlier: + +- **Machine-days, not users.** `daily_dim_counts.machines` is per day, so summing it over + a range counts a machine once per day it was active. A range-wide distinct count per + dimension value is not recoverable from the rollups at all, so the panels that use it + say "machine-days" and are share-of-total panels where the distinction does not move the + shape. Where a dimension rides several event types, the per-day figure is the largest + single-event count rather than their sum, so one machine's install + index + usage on + one day is not counted three times. +- **Recent cohorts have not finished converting.** A machine that installed yesterday has + not had seven days to run an index, so the tail of the activation curve is a floor, not + a result. The API marks those days (`complete: false`, `incomplete_from`) and the panel + says so instead of drawing a cliff and calling it a drop in conversion. Retention does + the same thing with a per-day denominator: day *k* is measured only over the machines + that have actually had *k* days to come back. + +## How the session works + +- The password is compared in constant time, over SHA-256 digests so the operands are always + the same length and nothing about the secret leaks through timing. +- The cookie is a signed assertion — `base64url(payload).base64url(HMAC-SHA256)` — not a + lookup key. There is no session store; a tampered payload fails the signature check. +- `HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age` one year. You sign in once per browser + and it survives restarts. +- The payload carries a fingerprint of the password it was minted against, so + **rotating `ADMIN_PASSWORD` signs everyone out** — that is the revocation story. +- Login attempts are capped at 5/min per IP. Unlike the ingest worker, which never reads the + client IP at all, this one does — solely as a rate-limit key, never stored or logged. + +## Deploy + +Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom domain +auto-provisions DNS + cert), and the D1 database from `telemetry-worker/` already created. + +```bash +cd telemetry-dashboard +npm install +npx wrangler login # once + +npx wrangler secret put ADMIN_PASSWORD # the shared password +npx wrangler secret put SESSION_SECRET # cookie-signing key, e.g. `openssl rand -base64 48` + +npm run deploy +``` + +Both secrets are required — the worker refuses every request if either is missing, so a +half-configured deployment fails closed rather than becoming an open dashboard. + +Rotating either one is a `wrangler secret put` away. Rotating `SESSION_SECRET` invalidates +outstanding cookies too, and is the right move if you think one leaked. + +Migrations belong to the writer, not to this worker: apply schema changes from +`telemetry-worker/` (`npm run db:migrate`). D1 is read-only here. + +## Local dev & checks + +```bash +cp .dev.vars.example .dev.vars # placeholder secrets; also feeds `wrangler types` +npm run check # vendor + wrangler types + tsc --noEmit + deploy --dry-run +npm run seed # load scripts/fixture.sql into the LOCAL D1 +npm run dev # http://localhost:8787 + +npm run smoke:auth # the auth gate (54 assertions) +npm run smoke:api # the SQL and its numbers (98 assertions) +npm run smoke:render # the panels, in a browser (79 assertions) +``` + +Each suite starts its own throwaway `wrangler dev` on its own port and cleans up after +itself, so they can be run in any order (`DASH_PORT` overrides the port). + +**`smoke-auth.sh`** is the regression net for the gate: unauthenticated requests reach +nothing (pages, API *and* static assets), the cookie is persistent and correctly flagged, +flipped/truncated/forged cookies are all rejected, brute force is capped, and rotating the +password invalidates existing sessions. Run it after touching `src/auth.ts` or the route +table in `src/index.ts`. + +**`smoke-api.sh`** checks every endpoint against `scripts/fixture.sql` — twelve machines +over ten days, listed machine by machine in that file's header, small enough that every +expected number was worked out by hand rather than recorded from a passing run. It also +covers the boring half: bad dims, malformed dates, backwards ranges and over-wide ranges. + +**`render-check.mjs`** loads the real page in whatever Chromium is already on the machine +(over the DevTools protocol — no new dependency; it *skips* if there is no browser) and +reads the live Chart.js instance behind each canvas, comparing what every panel plotted +against the same endpoint fetched from Node. That is what catches a panel wired to the +wrong dimension, which neither of the other two suites can see. It also drives the range +picker and asserts a clean console, so a CSP regression fails the build. +`RENDER_SHOT=/tmp/dash.png npm run smoke:render` writes a full-page screenshot — the only +way to check the things assertions cannot, like label collisions. + +## Frontend + +Plain static files in `public/` — one HTML page, ES modules, no framework, no build step. + +| File | Holds | +|---|---| +| `index.html` | The shell: masthead, the one filter row, an empty grid. | +| `panels.js` | The panel registry — data in, chart config out, no DOM. Adding a panel is one entry. | +| `theme.js` | Palette, formatters, and the Chart.js defaults every panel inherits. | +| `app.js` | The page: range picker, one fetch per panel, loading/empty/error states. | + +The split is what lets `render-check.mjs` import the *same* registry the browser just +rendered from, so its expectations cannot drift from the panels under test. + +Panels fail alone: each fetches, draws and reports independently, so a failed query leaves +the other eighteen on screen. There is no client-side cache — the only reuse is +deduplicating identical URLs within a single render (four stat tiles share one +`/api/summary`), and that map is discarded afterwards, so refresh really does re-ask. +A refetch dims the previous render rather than tearing it down, so nothing jumps. Every +chart has a "Show numbers" table twin, which is what keeps a value from being reachable +only by hovering. + +### Colours + +Two scales, both run through the data-viz validator against this dashboard's actual chart +surface (`#ffffff`, the panel fill) rather than picked by eye — the exact results are +recorded at the top of `theme.js`: + +- **Categorical** `#a8342a #2a6f9e #17916a #c98500` — identity (which series). Slot 1 is + the brand oxblood stepped up into the legible lightness band. Clears every gate + including all-pairs colour-vision separation, with no contrast relief needed. +- **Ordinal** `#d99a90 #c26a5c #a3423a #7a201a` — one hue, light to dark, for scales whose + order *is* their meaning (run length, codebase size), so the ordering is visible in the + colour instead of needing the legend. + +Nominal bars all take slot 1: colouring them by value would spend the identity channel +re-encoding what bar length already shows. If you change a hex, re-run the validator — the +red/green pair that "looks fine" is the one that collapses under deuteranopia. +Workers Static Assets serves them verbatim, so third-party libraries are copied out of +`node_modules` into `public/vendor/` by `npm run vendor` (wired into `dev` and `deploy`). +That keeps the version pinned by the lockfile, avoids a third-party origin at runtime, and +lets the CSP stay `script-src 'self'`. `public/vendor/` is gitignored — it is build output. + +Visual conventions follow the rest of codegraph: flat and editorial, square corners, hairline +rules, sentence-case headings, one oxblood accent, no tiny all-caps tracked labels. diff --git a/telemetry-dashboard/package-lock.json b/telemetry-dashboard/package-lock.json new file mode 100644 index 000000000..25d5ce551 --- /dev/null +++ b/telemetry-dashboard/package-lock.json @@ -0,0 +1,1577 @@ +{ + "name": "codegraph-telemetry-dashboard", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codegraph-telemetry-dashboard", + "devDependencies": { + "chart.js": "^4.4.0", + "typescript": "^5.0.0", + "wrangler": "^4.36.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260722.1.tgz", + "integrity": "sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260722.1.tgz", + "integrity": "sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260722.1.tgz", + "integrity": "sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260722.1.tgz", + "integrity": "sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260722.1.tgz", + "integrity": "sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260722.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260722.1.tgz", + "integrity": "sha512-FJIg4omaCb2wwSyOeRosEdmVRi3JzGAOOH3pa3twmmtvWECl6BVZMIDwJbjByLKRyU+mrfk0M/n3oSiojFZvSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260722.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260722.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260722.1.tgz", + "integrity": "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260722.1", + "@cloudflare/workerd-darwin-arm64": "1.20260722.1", + "@cloudflare/workerd-linux-64": "1.20260722.1", + "@cloudflare/workerd-linux-arm64": "1.20260722.1", + "@cloudflare/workerd-windows-64": "1.20260722.1" + } + }, + "node_modules/wrangler": { + "version": "4.115.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.115.0.tgz", + "integrity": "sha512-+upG2VW66M1sjb43yzgUZ6Ss8iYpJ6+7F3U4GF8TY5EYd+08sYdS54d24AEwCnhuef3J9KlSPusqiqR9WYy1UA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260722.1", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260722.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260722.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/telemetry-dashboard/package.json b/telemetry-dashboard/package.json new file mode 100644 index 000000000..de34e8fd0 --- /dev/null +++ b/telemetry-dashboard/package.json @@ -0,0 +1,22 @@ +{ + "name": "codegraph-telemetry-dashboard", + "private": true, + "type": "module", + "description": "Password-gated admin dashboard over the codegraph telemetry D1 database (stats.getcodegraph.com)", + "scripts": { + "vendor": "node scripts/vendor-assets.mjs", + "dev": "npm run vendor && wrangler dev", + "deploy": "npm run vendor && wrangler deploy", + "types": "wrangler types", + "check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run", + "seed": "./scripts/seed-fixture.sh", + "smoke:auth": "./scripts/smoke-auth.sh", + "smoke:api": "./scripts/smoke-api.sh", + "smoke:render": "npm run vendor && node scripts/render-check.mjs" + }, + "devDependencies": { + "chart.js": "^4.4.0", + "typescript": "^5.0.0", + "wrangler": "^4.36.0" + } +} diff --git a/telemetry-dashboard/public/app.js b/telemetry-dashboard/public/app.js new file mode 100644 index 000000000..2c7f3a5f0 --- /dev/null +++ b/telemetry-dashboard/public/app.js @@ -0,0 +1,395 @@ +/** + * The dashboard page: one filter row, a grid of panels, and a fetch per panel. + * + * Deliberate properties: + * - **One filter row, above everything it scopes.** Changing the range or + * hitting refresh re-queries every panel against the same slice; no panel + * carries its own time control. + * - **Panels fail alone.** Each one fetches, draws, and reports independently, + * so a 503 on one query leaves the other eighteen on screen instead of + * blanking the page. + * - **No client-side cache.** The only reuse is deduplicating identical URLs + * within a single render (four stat tiles read one /api/summary); that map is + * thrown away afterwards, so refresh really does re-ask. Anything longer-lived + * is the API's `Cache-Control` doing its job in the browser's own cache. + * - **No skeleton flash.** A refetch dims the previous render instead of tearing + * it down, so nothing jumps while new numbers land. + * - **Every chart has a table twin.** "Show numbers" reveals the same data as + * text, which is what keeps a value from being reachable only by hovering. + */ + +import { PANELS } from './panels.js'; +import { applyChartDefaults, shortDay } from './theme.js'; + +const RANGE_PRESETS = [ + { days: 7, label: 'Last 7 days' }, + { days: 14, label: 'Last 14 days' }, + { days: 30, label: 'Last 30 days' }, + { days: 90, label: 'Last 90 days' }, +]; +const DEFAULT_PRESET = 30; +const DAY_MS = 86_400_000; + +const Chart = window.Chart; + +/** Every fetch goes through here so an expired session lands on /login instead + * of failing silently mid-render. */ +export async function api(path) { + const response = await fetch(path, { headers: { accept: 'application/json' } }); + if (response.status === 401) { + window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`; + throw new Error('session expired'); + } + if (!response.ok) { + const detail = await response.json().catch(() => null); + throw new Error(detail?.error ?? `responded ${response.status}`); + } + return response.json(); +} + +// --------------------------------------------------------------------------- +// Days +// --------------------------------------------------------------------------- + +const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10); +const dayMs = (day) => Date.parse(`${day}T00:00:00Z`); +const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS); +const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value)); + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +const state = { + /** Latest day the nightly rollup has written; every preset ends here. */ + anchor: utcDay(Date.now()), + earliest: null, + preset: DEFAULT_PRESET, + custom: { from: null, to: null }, + /** Panels whose table twin the reader has opened, kept across re-renders. */ + openTables: new Set(), + renderToken: 0, +}; + +const charts = new Map(); + +function currentRange() { + if (state.preset === 'custom' && state.custom.from && state.custom.to) { + return { from: state.custom.from, to: state.custom.to }; + } + const to = state.anchor; + return { from: addDays(to, -(state.preset - 1)), to }; +} + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +const $ = (root, role) => root.querySelector(`[data-role="${role}"]`); + +// --------------------------------------------------------------------------- +// Building the page +// --------------------------------------------------------------------------- + +function buildFilters() { + const bar = document.getElementById('filters'); + const presets = $(bar, 'presets'); + + for (const preset of RANGE_PRESETS) { + const button = el('button', 'range', preset.label); + button.type = 'button'; + button.dataset.days = String(preset.days); + button.addEventListener('click', () => { + state.preset = preset.days; + syncFilters(); + render(); + }); + presets.append(button); + } + + const from = $(bar, 'custom-from'); + const to = $(bar, 'custom-to'); + const apply = $(bar, 'custom-apply'); + apply.addEventListener('click', () => { + if (!isDay(from.value) || !isDay(to.value)) { + setRangeSummary('Enter both dates as YYYY-MM-DD.'); + return; + } + if (from.value > to.value) { + setRangeSummary('The start date must come before the end date.'); + return; + } + state.preset = 'custom'; + state.custom = { from: from.value, to: to.value }; + syncFilters(); + render(); + }); + + $(bar, 'refresh').addEventListener('click', () => { + refreshMeta().finally(render); + }); +} + +function syncFilters() { + const bar = document.getElementById('filters'); + for (const button of bar.querySelectorAll('button.range')) { + const selected = String(state.preset) === button.dataset.days; + button.classList.toggle('is-selected', selected); + button.setAttribute('aria-pressed', String(selected)); + } + const { from, to } = currentRange(); + $(bar, 'custom-from').value = from; + $(bar, 'custom-to').value = to; +} + +function setRangeSummary(text) { + document.getElementById('range-summary').textContent = text; +} + +function buildPanels() { + const grid = document.getElementById('grid'); + for (const panel of PANELS) { + const section = el('section', `panel span-${panel.span}`); + section.id = `panel-${panel.id}`; + section.dataset.panel = panel.id; + section.dataset.state = 'loading'; + + const head = el('div', 'panel-head'); + head.append(el('h2', null, panel.title)); + const figure = el('p', 'panel-figure'); + figure.dataset.role = 'figure'; + head.append(figure); + section.append(head); + + if (panel.note) section.append(el('p', 'panel-note', panel.note)); + + const body = el('div', 'panel-body'); + body.dataset.role = 'body'; + if (panel.kind === 'chart') { + const wrap = el('div', 'chart-wrap'); + const canvas = document.createElement('canvas'); + canvas.dataset.role = 'canvas'; + // Chart.js renders to canvas, so the accessible copy is the table twin + // below — say so rather than leaving a bare graphic. + canvas.setAttribute('role', 'img'); + canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`); + wrap.append(canvas); + body.append(wrap); + } else if (panel.kind === 'stat') { + const stat = el('div', 'stat'); + stat.dataset.role = 'stat'; + stat.append(el('p', 'stat-value'), el('p', 'stat-caption')); + body.append(stat); + } else if (panel.kind === 'funnel') { + const funnel = el('div', 'funnel'); + funnel.dataset.role = 'funnel'; + body.append(funnel); + } + + const status = el('p', 'panel-state'); + status.dataset.role = 'state'; + body.append(status); + section.append(body); + + const toggle = el('button', 'link', 'Show numbers'); + toggle.type = 'button'; + toggle.dataset.role = 'toggle'; + toggle.setAttribute('aria-expanded', 'false'); + const table = el('div', 'table-wrap'); + table.dataset.role = 'table'; + table.hidden = true; + toggle.addEventListener('click', () => { + const open = table.hidden; + table.hidden = !open; + toggle.textContent = open ? 'Hide numbers' : 'Show numbers'; + toggle.setAttribute('aria-expanded', String(open)); + if (open) state.openTables.add(panel.id); + else state.openTables.delete(panel.id); + }); + section.append(toggle, table); + + grid.append(section); + } +} + +// --------------------------------------------------------------------------- +// Drawing one panel +// --------------------------------------------------------------------------- + +function setState(section, name, message) { + section.dataset.state = name; + $(section, 'state').textContent = message ?? ''; +} + +function drawTable(section, spec) { + const host = $(section, 'table'); + host.replaceChildren(); + if (!spec) return; + + const table = el('table'); + const thead = el('thead'); + const headRow = el('tr'); + for (const column of spec.columns) { + const th = el('th', null, column); + th.scope = 'col'; + headRow.append(th); + } + thead.append(headRow); + + const tbody = el('tbody'); + for (const row of spec.rows) { + const tr = el('tr'); + row.forEach((cell, i) => { + const node = el(i === 0 ? 'th' : 'td', null, String(cell)); + if (i === 0) node.scope = 'row'; + tr.append(node); + }); + tbody.append(tr); + } + table.append(thead, tbody); + host.append(table); +} + +function drawStat(section, stat) { + const host = $(section, 'stat'); + host.querySelector('.stat-value').textContent = stat.value; + host.querySelector('.stat-caption').textContent = stat.caption ?? ''; +} + +/** + * The two-stage conversion funnel, drawn as proportional bars rather than a + * chart: two bars and a percentage is the whole story, and a two-slice pie or a + * two-bar chart would be more chrome than data. + */ +function drawFunnel(section, funnel) { + const host = $(section, 'funnel'); + host.replaceChildren(); + + for (const stage of funnel.stages) { + const row = el('div', 'funnel-stage'); + const head = el('div', 'funnel-label'); + head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US'))); + const track = el('div', 'funnel-track'); + const fill = el('div', 'funnel-fill'); + // Width is the datum, so it is set from JS rather than a style attribute — + // the CSP here allows no inline styles at all. + fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`; + track.append(fill); + row.append(head, track); + host.append(row); + } + + const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`; + host.append( + el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`), + ); +} + +function drawChart(section, panel, config) { + const canvas = $(section, 'canvas'); + const existing = charts.get(panel.id); + if (existing) existing.destroy(); + charts.set(panel.id, new Chart(canvas, config)); +} + +async function drawPanel(panel, request, token) { + const section = document.getElementById(`panel-${panel.id}`); + section.dataset.stale = 'true'; + + try { + const data = await request; + // A slower panel from a superseded render must never overwrite the current one. + if (token !== state.renderToken) return; + + if (panel.empty?.(data)) { + setState(section, 'empty', 'Nothing in this range.'); + drawTable(section, panel.table?.(data)); + return; + } + + if (panel.kind === 'stat') drawStat(section, panel.stat(data)); + else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data)); + else drawChart(section, panel, panel.chart(data)); + + $(section, 'figure').textContent = panel.figure ? panel.figure(data) : ''; + drawTable(section, panel.table?.(data)); + setState(section, 'ready'); + } catch (err) { + if (token !== state.renderToken) return; + // One panel's failure is one panel's problem: the message lands in the + // panel, the rest of the page keeps its data. + setState(section, 'error', `Could not load this panel — ${err.message ?? err}`); + const chart = charts.get(panel.id); + if (chart) { + chart.destroy(); + charts.delete(panel.id); + } + } finally { + if (token === state.renderToken) section.dataset.stale = 'false'; + } +} + +// --------------------------------------------------------------------------- +// Rendering everything +// --------------------------------------------------------------------------- + +async function refreshMeta() { + try { + const meta = await api('/api/meta'); + if (meta.latest_day) state.anchor = meta.latest_day; + state.earliest = meta.earliest_day ?? null; + syncFilters(); + } catch { + // A meta failure is not fatal: the picker falls back to today's date and + // every panel still answers. The banner is what says so. + document.getElementById('data-through').textContent = 'Could not read the data range.'; + } +} + +async function render() { + const token = ++state.renderToken; + const { from, to } = currentRange(); + const query = `from=${from}&to=${to}`; + + setRangeSummary(`${shortDay(from)} – ${shortDay(to)}, ${to.slice(0, 4)}`); + document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`; + + // Deduplicate identical URLs within THIS render only — the four stat tiles + // share one /api/summary. Discarded when the render ends, so refresh refetches. + const inFlight = new Map(); + const request = (path) => { + if (!inFlight.has(path)) inFlight.set(path, api(path)); + return inFlight.get(path); + }; + + await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token))); + + if (token === state.renderToken) { + document.getElementById('refreshed-at').textContent = + `Last refreshed ${new Date().toLocaleTimeString('en-US')}`; + document.body.dataset.ready = 'true'; + } +} + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- + +if (!Chart) { + document.getElementById('data-through').textContent = + 'The chart library did not load — run `npm run vendor` and reload.'; +} else { + applyChartDefaults(Chart); + buildFilters(); + buildPanels(); + syncFilters(); + await refreshMeta(); + await render(); +} diff --git a/telemetry-dashboard/public/index.html b/telemetry-dashboard/public/index.html new file mode 100644 index 000000000..c57342073 --- /dev/null +++ b/telemetry-dashboard/public/index.html @@ -0,0 +1,52 @@ + + + + + + codegraph telemetry + + + +
+
+

codegraph telemetry

+

Anonymous usage from the public engine, straight out of D1.

+
+
+ +
+
+ + +
+
+ +
+ + + + + +
+ +
+ +
+ +

+ Loading… + + + + +

+
+ +
+ + + + + diff --git a/telemetry-dashboard/public/panels.js b/telemetry-dashboard/public/panels.js new file mode 100644 index 000000000..120d8b4f4 --- /dev/null +++ b/telemetry-dashboard/public/panels.js @@ -0,0 +1,534 @@ +/** + * The panel registry — what the dashboard shows, in the order it shows it. + * + * Every panel is data in, chart config out, with no DOM anywhere in this file: + * app.js owns the page, this owns the mapping from an API response to a chart. + * Keeping them apart is what lets scripts/render-check.mjs drive the real panel + * definitions in a real browser and compare what each one plotted against what + * the API returned. + * + * A panel is: + * id stable key, also the DOM id and the anchor in a bug report + * title sentence case, at a readable size — never a tracked-out caps label + * note the honest footnote: what the number actually counts + * span grid columns out of 12 + * source (query) => API path; panels sharing a path share one fetch + * kind 'stat' | 'funnel' | 'chart' + * figure optional headline shown under the title (pie totals) + * empty (data) => is there nothing to draw + * table (data) => the WCAG-clean twin every chart owes the reader + */ + +import { + CATEGORICAL, + INDEX_HOVER, + NEUTRAL, + SURFACE, + categoryScale, + compact, + number, + paletteFor, + percent, + shortDay, + valueScale, +} from './theme.js'; + +// --------------------------------------------------------------------------- +// Sources +// --------------------------------------------------------------------------- + +const summary = (q) => `/api/summary?${q}`; +const activation = (q) => `/api/activation?${q}`; +const retention = (q) => `/api/retention?${q}`; +const series = (metric) => (q) => `/api/timeseries?metric=${metric}&${q}`; +const breakdown = + (dim, extra = '') => + (q) => + `/api/breakdown?dim=${dim}${extra}&${q}`; + +// --------------------------------------------------------------------------- +// Chart builders +// --------------------------------------------------------------------------- + +const allZero = (data) => data.datasets.every((ds) => ds.data.every((v) => !v)); +const noRows = (data) => data.labels.length === 0 || data.datasets[0].data.every((v) => !v); + +/** Alpha-suffixed hex for the ~10% area wash under a single-series line. */ +const wash = (hex) => `${hex}1a`; + +/** + * A line per series over days. One axis, always — two measures of different + * scale get two panels rather than a second y-axis, which would invent a + * correlation the data does not have. + */ +function lineChart(data, { unit = 'count' } = {}) { + const dense = data.labels.length > 21; + const isPercent = unit === 'percent'; + // A wash under a single line reads well — but not across gaps, where the fill + // would colour in days the series has no value for. Days with no cohort at + // all are exactly that case, so a gapped series goes unfilled. + const gapped = data.datasets.some((ds) => ds.data.some((v) => v === null)); + const single = data.datasets.length === 1 && !gapped; + + return { + type: 'line', + data: { + labels: data.labels.map(shortDay), + datasets: data.datasets.map((ds, i) => { + const colour = CATEGORICAL[i] ?? NEUTRAL; + return { + label: ds.label, + data: ds.data, + borderColor: colour, + backgroundColor: single ? wash(colour) : colour, + fill: single, + // Dots on a 90-day line are noise; the index-mode tooltip is how you + // read a value, and the table view is how you read all of them. + pointRadius: dense ? 0 : 3, + pointHoverRadius: 5, + pointBackgroundColor: colour, + // 2px surface ring, so a marker stays legible where lines cross. + pointBorderColor: SURFACE, + pointBorderWidth: 2, + spanGaps: false, + }; + }), + }, + options: { + interaction: INDEX_HOVER, + plugins: { + // A single series needs no legend box — the panel title names it. + legend: { display: data.datasets.length > 1 }, + tooltip: { + callbacks: { + label: (ctx) => + `${ctx.dataset.label}: ${ + ctx.parsed.y === null ? 'no data' : isPercent ? `${ctx.parsed.y}%` : number(ctx.parsed.y) + }`, + }, + }, + }, + scales: { + x: categoryScale(), + y: valueScale( + isPercent + ? { max: 100, ticks: { color: undefined, padding: 8, callback: (v) => `${v}%` } } + : {}, + ), + }, + }, + }; +} + +/** + * Bands stacked to the day's total, for an ordered split of one measure. + * + * Four separate lines is the wrong form here: same-hue ordinal steps crossing + * each other read as scribble, and the question ("how is run length shifting?") + * is part-to-whole, not four independent trends. Stacked, the band heights are + * the mix and the outline is the total. The 2px surface-coloured border is the + * gap between touching fills — white doing the separating, not a stroke. + */ +function stackedAreaChart(data) { + const colours = paletteFor( + data.datasets.map((ds) => ds.label), + 'ordinal', + ); + const config = lineChart(data); + config.data.datasets.forEach((ds, i) => { + ds.backgroundColor = colours[i]; + ds.borderColor = SURFACE; + ds.borderWidth = 2; + ds.pointRadius = 0; + ds.pointHoverRadius = 4; + ds.pointBackgroundColor = colours[i]; + ds.pointBorderColor = SURFACE; + ds.fill = true; + }); + config.options.scales.y.stacked = true; + // The swatch has to be the band's colour; the line is surface-coloured here. + config.options.plugins.legend = { + display: true, + labels: { generateLabels: () => data.datasets.map((ds, i) => ({ + text: ds.label, + fillStyle: colours[i], + strokeStyle: colours[i], + pointStyle: 'circle', + datasetIndex: i, + })) }, + }; + return config; +} + +/** + * Horizontal bars. `scale: 'ordinal'` is for categories whose order is their + * meaning (run length, codebase size) and takes the one-hue ramp; nominal + * categories all take slot 1, because colouring them by value would spend the + * identity channel re-encoding what bar length already says. + */ +function barChart(data, { scale = 'nominal' } = {}) { + const colours = + scale === 'ordinal' + ? paletteFor(data.labels, 'ordinal') + : data.labels.map((label) => (label === 'Other' ? NEUTRAL : CATEGORICAL[0])); + + return { + type: 'bar', + data: { + labels: data.labels, + datasets: [ + { + label: data.datasets[0].label, + data: data.datasets[0].data, + backgroundColor: colours, + maxBarThickness: 24, + // Rounded at the data end, square at the baseline (Chart.js skips the + // 'start' edge by default, which is the baseline on a horizontal bar). + borderRadius: 4, + }, + ], + }, + options: { + indexAxis: 'y', + plugins: { legend: { display: false } }, + scales: { + x: valueScale(), + y: categoryScale({ ticks: { color: undefined, padding: 6, autoSkip: false } }), + }, + }, + }; +} + +/** Part-to-whole at a glance. Capped at a handful of slices by the API's `limit`. */ +function pieChart(data, { scale = 'categorical' } = {}) { + const total = data.datasets[0].data.reduce((n, v) => n + v, 0); + return { + type: 'pie', + data: { + labels: data.labels, + datasets: [ + { + label: data.datasets[0].label, + data: data.datasets[0].data, + backgroundColor: paletteFor(data.labels, scale === 'ordinal' ? 'ordinal' : 'categorical'), + }, + ], + }, + options: { + plugins: { + legend: { display: true }, + tooltip: { + callbacks: { + label: (ctx) => + `${ctx.label}: ${number(ctx.parsed)} (${total > 0 ? percent(ctx.parsed / total, 1) : '—'})`, + }, + }, + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Table twins +// --------------------------------------------------------------------------- + +/** Days down the side, one column per series. */ +const seriesTable = (data) => ({ + columns: ['Day', ...data.datasets.map((ds) => ds.label)], + rows: data.labels.map((day, i) => [ + day, + ...data.datasets.map((ds) => (ds.data[i] === null ? '—' : number(ds.data[i]))), + ]), +}); + +/** Both numbers, always — the panel plots one of them, the table shows both. */ +const breakdownTable = (data) => ({ + columns: [data.title, 'Events', 'Machine-days'], + rows: data.rows.map((r) => [r.value, number(r.count), number(r.machines)]), +}); + +// --------------------------------------------------------------------------- +// The panels +// --------------------------------------------------------------------------- + +export const PANELS = [ + { + id: 'production-users', + title: 'Production users', + note: 'Distinct machines active in the range, excluding CI runners.', + span: 3, + kind: 'stat', + source: summary, + stat: (d) => ({ value: compact(d.production_users), caption: `${number(d.active_machines)} including CI` }), + table: (d) => ({ + columns: ['Measure', 'Machines'], + rows: [ + ['Production users', number(d.production_users)], + ['All active machines', number(d.active_machines)], + ['First seen in range', number(d.new_machines)], + ], + }), + }, + { + id: 'installs', + title: 'Installs', + note: 'Install events, including upgrades and reinstalls.', + span: 3, + kind: 'stat', + source: summary, + stat: (d) => ({ value: compact(d.installs), caption: `${number(d.new_machines)} from machines never seen before` }), + table: (d) => ({ + columns: ['Measure', 'Events'], + rows: [ + ['Installs', number(d.installs)], + ['New machines', number(d.new_machines)], + ], + }), + }, + { + id: 'uninstalls', + title: 'Uninstalls', + note: 'Uninstall events in the range.', + span: 3, + kind: 'stat', + source: summary, + stat: (d) => ({ + value: compact(d.uninstalls), + caption: d.installs > 0 ? `${percent(d.uninstalls / d.installs)} of installs` : 'No installs in range', + }), + table: (d) => ({ + columns: ['Measure', 'Events'], + rows: [ + ['Uninstalls', number(d.uninstalls)], + ['Installs', number(d.installs)], + ], + }), + }, + { + id: 'indexing-runs', + title: 'Indexing runs', + note: 'Index events in the range, across every machine.', + span: 3, + kind: 'stat', + source: summary, + stat: (d) => ({ value: compact(d.index_runs), caption: `${compact(d.tool_calls)} tool and command calls` }), + table: (d) => ({ + columns: ['Measure', 'Events'], + rows: [ + ['Indexing runs', number(d.index_runs)], + ['Tool and command calls', number(d.tool_calls)], + ], + }), + }, + + { + id: 'activation-funnel', + title: 'Install to first use', + note: 'Machines first seen in the range that ran an index within 7 days.', + span: 4, + kind: 'funnel', + source: activation, + empty: (d) => d.installs === 0, + funnel: (d) => ({ + stages: [ + { label: 'Installed', value: d.installs, share: 1 }, + { + label: `Indexed within ${d.window_days} days`, + value: d.activated, + share: d.installs > 0 ? d.activated / d.installs : 0, + }, + ], + rate: d.rate, + dropped: d.dropped, + }), + table: (d) => ({ + columns: ['Stage', 'Machines', 'Share'], + rows: [ + ['Installed', number(d.installs), '100%'], + [`Indexed within ${d.window_days} days`, number(d.activated), percent(d.rate)], + ['Dropped off', number(d.dropped), percent(d.installs > 0 ? d.dropped / d.installs : null)], + ], + }), + }, + { + id: 'activation-rate', + title: 'Conversion rate over time', + note: 'By the day a machine was first seen. Recent days are still converting, so their rate only rises.', + span: 8, + kind: 'chart', + source: activation, + empty: (d) => d.installs === 0, + chart: (d) => lineChart(d, { unit: 'percent' }), + table: (d) => ({ + columns: ['Day', 'Installs', 'Indexed', 'Rate', 'Window elapsed'], + rows: d.rows.map((r) => [ + r.day, + number(r.installs), + number(r.activated), + percent(r.rate), + r.complete ? 'Yes' : 'Not yet', + ]), + }), + }, + + { + id: 'os', + title: 'Users by operating system', + note: 'Share of machine-days: a machine active on several days counts once per day.', + span: 4, + kind: 'chart', + // Three hues plus a neutral "Other" — the point past which categorical + // colours stop being reliably distinguishable under colour-vision deficiency. + source: breakdown('os', '&limit=3'), + empty: noRows, + figure: (d) => `${compact(d.total)} machine-days`, + chart: (d) => pieChart(d), + table: breakdownTable, + }, + { + id: 'run-length', + title: 'Session run length', + note: 'Indexing runs by how long they took.', + span: 4, + kind: 'chart', + source: breakdown('duration_bucket'), + empty: noRows, + figure: (d) => `${compact(d.total)} runs`, + chart: (d) => pieChart(d, { scale: 'ordinal' }), + table: breakdownTable, + }, + { + id: 'codebase-size', + title: 'Codebase size', + note: 'Files per indexed project.', + span: 4, + kind: 'chart', + source: breakdown('file_count_bucket'), + empty: noRows, + chart: (d) => barChart(d, { scale: 'ordinal' }), + table: breakdownTable, + }, + + { + id: 'installs-uninstalls', + title: 'Installs and uninstalls over time', + note: 'Install and uninstall events per day.', + span: 6, + kind: 'chart', + source: series('installs_uninstalls'), + empty: allZero, + chart: (d) => lineChart(d), + table: seriesTable, + }, + { + id: 'new-installs', + title: 'New installs over time', + note: 'Machines seen for the first time, by day.', + span: 6, + kind: 'chart', + source: series('new_installs'), + empty: allZero, + chart: (d) => lineChart(d), + table: seriesTable, + }, + { + id: 'indexing-activity', + title: 'Daily indexing activity', + note: 'Indexing runs and the machines that ran them.', + span: 6, + kind: 'chart', + source: series('indexing_activity'), + empty: allZero, + chart: (d) => lineChart(d), + table: seriesTable, + }, + { + id: 'daily-production-users', + title: 'Daily production users', + note: 'Distinct machines active each day, excluding CI runners.', + span: 6, + kind: 'chart', + source: series('production_users'), + empty: allZero, + chart: (d) => lineChart(d), + table: seriesTable, + }, + { + id: 'run-length-over-time', + title: 'Run length over time', + note: 'Indexing runs per day, split by how long they took.', + span: 6, + kind: 'chart', + source: series('duration_buckets'), + empty: allZero, + // Ordered buckets, so the bands take the one-hue ramp rather than four + // unrelated hues: the reader sees "longer" in the colour. + chart: stackedAreaChart, + table: seriesTable, + }, + { + id: 'retention', + title: 'Daily retention cohorts', + note: 'Machines first seen in the range, and the share still active k days later.', + span: 6, + kind: 'chart', + source: retention, + empty: (d) => d.cohort === 0, + figure: (d) => `${compact(d.cohort)} machines in cohort`, + chart: (d) => lineChart(d, { unit: 'percent' }), + table: (d) => ({ + columns: ['Day', 'Machines old enough', 'Still active', 'Rate'], + rows: d.rows.map((r) => [ + `Day ${r.day}`, + number(r.eligible), + number(r.retained), + percent(r.rate), + ]), + }), + }, + + { + id: 'languages', + title: 'Most-indexed programming languages', + note: 'One count per indexing run that found the language; a mixed repo counts under each.', + span: 6, + kind: 'chart', + source: breakdown('language'), + empty: noRows, + chart: (d) => barChart(d), + table: breakdownTable, + }, + { + id: 'indexing-speed', + title: 'Indexing speed', + note: 'Indexing runs by duration bucket.', + span: 6, + kind: 'chart', + source: breakdown('duration_bucket'), + empty: noRows, + chart: (d) => barChart(d, { scale: 'ordinal' }), + table: breakdownTable, + }, + { + id: 'versions', + title: 'Users by app version', + note: 'Machine-days per version, newest first.', + span: 6, + kind: 'chart', + source: breakdown('codegraph_version'), + empty: noRows, + chart: (d) => barChart(d), + table: breakdownTable, + }, + { + id: 'targets', + title: 'AI agent targets', + note: 'Agents wired up at install time. One install can configure several.', + span: 6, + kind: 'chart', + source: breakdown('target'), + empty: noRows, + chart: (d) => barChart(d), + table: breakdownTable, + }, +]; diff --git a/telemetry-dashboard/public/styles.css b/telemetry-dashboard/public/styles.css new file mode 100644 index 000000000..73ec9751c --- /dev/null +++ b/telemetry-dashboard/public/styles.css @@ -0,0 +1,345 @@ +/* Flat and editorial: square corners, hairline rules, sentence-case headings, + one oxblood accent. Matches getcodegraph.com. + + No tiny all-caps tracked-out labels anywhere — panel titles are real headings + at a readable size, and the fine print under them is sentence case. */ + +:root { + --paper: #f7f6f2; + --surface: #ffffff; + --ink: #16150f; + --secondary: #56534a; + --muted: #807d74; + --oxblood: #7a201a; + --rule: #d8d5cb; + --hairline: #e7e5de; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 24px; + background: var(--paper); + color: var(--ink); + font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 16px; + line-height: 1.5; +} + +.masthead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--rule); +} + +h1 { + margin: 0 0 4px; + font-size: 22px; + font-weight: 600; +} + +h2 { + margin: 0; + font-size: 17px; + font-weight: 600; +} + +.subtitle { + margin: 0; + color: var(--secondary); +} + +/* --- filter row --------------------------------------------------------- */ + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 20px; + padding: 16px 0; + border-bottom: 1px solid var(--rule); +} + +.filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.filter-end { + margin-left: auto; +} + +.custom-range label { + color: var(--secondary); +} + +.custom-range input { + padding: 7px 10px; + font: inherit; + font-size: 15px; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 0; +} + +.custom-range input:focus-visible, +button:focus-visible { + outline: 2px solid var(--oxblood); + outline-offset: 1px; +} + +.filter-status { + flex-basis: 100%; + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.filter-status .dot { + padding: 0 4px; +} + +/* --- buttons ------------------------------------------------------------ */ + +button { + padding: 8px 14px; + font: inherit; + font-size: 15px; + color: var(--paper); + background: var(--oxblood); + border: 1px solid var(--oxblood); + border-radius: 0; + cursor: pointer; +} + +button.secondary, +button.range { + color: var(--ink); + background: transparent; + border-color: var(--rule); +} + +button.secondary:hover, +button.range:hover { + border-color: var(--ink); +} + +button.range.is-selected { + color: var(--paper); + background: var(--oxblood); + border-color: var(--oxblood); +} + +button.link { + align-self: flex-start; + margin-top: 12px; + padding: 0; + color: var(--oxblood); + background: none; + border: none; + font-size: 14px; + text-decoration: underline; + text-underline-offset: 2px; +} + +/* --- grid --------------------------------------------------------------- */ + +.grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 16px; + margin-top: 24px; +} + +.span-3 { grid-column: span 3; } +.span-4 { grid-column: span 4; } +.span-6 { grid-column: span 6; } +.span-8 { grid-column: span 8; } +.span-12 { grid-column: span 12; } + +/* A laptop is the target; below that the columns just widen rather than + pretending to be a phone layout. */ +@media (max-width: 1180px) { + .span-3 { grid-column: span 6; } + .span-4, + .span-8 { grid-column: span 6; } +} + +@media (max-width: 760px) { + .span-3, + .span-4, + .span-6, + .span-8 { grid-column: span 12; } +} + +/* --- panels ------------------------------------------------------------- */ + +.panel { + display: flex; + flex-direction: column; + padding: 16px; + background: var(--surface); + border: 1px solid var(--rule); +} + +.panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.panel-figure { + margin: 0; + color: var(--secondary); + font-size: 14px; + white-space: nowrap; +} + +.panel-note { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.panel-body { + flex: 1; + margin-top: 12px; + /* Refetch dims the previous render instead of tearing it down — no skeleton + flash, no layout jump. */ + transition: opacity 120ms ease-out; +} + +.panel[data-stale='true'] .panel-body { + opacity: 0.55; +} + +.panel-state { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.panel[data-state='ready'] .panel-state { + display: none; +} + +.panel[data-state='error'] .panel-state { + color: var(--oxblood); +} + +/* Until a panel has data there is nothing to show but its state line. */ +.panel:not([data-state='ready']) .chart-wrap, +.panel:not([data-state='ready']) .stat, +.panel:not([data-state='ready']) .funnel { + display: none; +} + +/* Height covers the plot AND the axis band, so a panel never grows its own + little scrollbar. */ +.chart-wrap { + position: relative; + height: 232px; +} + +/* --- stat tiles --------------------------------------------------------- */ + +.stat-value { + margin: 4px 0 0; + font-size: 40px; + font-weight: 600; + line-height: 1.1; + /* Proportional figures on purpose: tabular-nums makes a number like 121 look + loose at display sizes. Tabular is for the table below. */ +} + +.stat-caption { + margin: 6px 0 0; + color: var(--muted); + font-size: 14px; +} + +/* --- funnel ------------------------------------------------------------- */ + +.funnel-stage + .funnel-stage { + margin-top: 16px; +} + +.funnel-label { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--secondary); + font-size: 14px; +} + +.funnel-value { + color: var(--ink); + font-size: 18px; + font-weight: 600; +} + +.funnel-track { + height: 10px; + margin-top: 6px; + background: var(--hairline); +} + +.funnel-fill { + height: 100%; + background: var(--oxblood); +} + +.funnel-summary { + margin: 16px 0 0; + color: var(--secondary); + font-size: 14px; +} + +/* --- table twins -------------------------------------------------------- */ + +.table-wrap { + margin-top: 12px; + max-height: 260px; + overflow-y: auto; +} + +.table-wrap table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + /* Columns of numbers that align vertically — the one place tabular figures + are the right call. */ + font-variant-numeric: tabular-nums; +} + +.table-wrap th, +.table-wrap td { + padding: 5px 8px 5px 0; + text-align: left; + border-bottom: 1px solid var(--hairline); +} + +.table-wrap thead th { + position: sticky; + top: 0; + background: var(--surface); + color: var(--secondary); + font-weight: 600; +} + +.table-wrap tbody th { + font-weight: 400; +} + +.table-wrap td { + color: var(--secondary); +} diff --git a/telemetry-dashboard/public/theme.js b/telemetry-dashboard/public/theme.js new file mode 100644 index 000000000..14c5836d2 --- /dev/null +++ b/telemetry-dashboard/public/theme.js @@ -0,0 +1,195 @@ +/** + * Chart theme — the colours and the Chart.js defaults every panel inherits. + * + * The palette is not eyeballed. Both scales below were run through the data-viz + * validator against this dashboard's actual chart surface (#ffffff, the panel + * fill — not the page's paper), and both clear every hard gate: + * + * categorical #a8342a,#2a6f9e,#17916a,#c98500 (light, surface #ffffff, --pairs all) + * lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair + * ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) · + * contrast PASS (all ≥ 3:1, so no panel depends on the relief rule) + * + * ordinal #d99a90,#c26a5c,#a3423a,#7a201a (light, surface #ffffff, --ordinal) + * monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1 + * PASS · single hue PASS (spread 3°) + * + * If you change a hex, re-run the validator rather than trusting your eye — + * the red/green pair that "looks fine" is the one that collapses under + * deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence, + * never cycle, and fold a ninth series into "Other". + */ + +/** Panel fill — the surface every contrast number above was measured against. */ +export const SURFACE = '#ffffff'; +export const INK = '#16150f'; +export const SECONDARY = '#56534a'; +export const MUTED = '#807d74'; +export const GRID = '#e7e5de'; +export const AXIS = '#c9c6bc'; + +/** + * Categorical — identity. Slot 1 is the brand oxblood stepped up into the + * lightness band (#7a201a itself is too dark to sit in a categorical scale). + */ +export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500']; + +/** + * Neutral, deliberately outside the categorical scale: "Other" is a leftover, + * not a series, and should not read as one. + */ +export const NEUTRAL = '#8d8a80'; + +/** + * Ordinal — order IS the meaning (run length, codebase size). One hue, light to + * dark, so the reader sees the ordering in the colour instead of decoding a legend. + */ +export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a']; + +/** Identity by position, never by rank — a filter must not repaint the survivors. */ +export function categorical(index) { + return CATEGORICAL[index] ?? NEUTRAL; +} + +/** + * Colours for an ordered set of n marks. Four buckets map onto the ramp exactly; + * a shorter set is spread across it so the light→dark reading survives. Anything + * past the ramp (an unexpected bucket from an old client) goes neutral rather + * than inventing a step that would misstate the order. + */ +export function ordinal(n) { + if (n <= 0) return []; + if (n === 1) return [ORDINAL[2]]; + const out = []; + for (let i = 0; i < n; i++) { + out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL); + } + return out; +} + +/** "Other" keeps the neutral wherever the API folded a tail into it. */ +export function paletteFor(labels, scale) { + const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i)); + return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i])); +} + +// --------------------------------------------------------------------------- +// Formatting +// --------------------------------------------------------------------------- + +const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }); +const PLAIN = new Intl.NumberFormat('en-US'); + +/** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */ +export function compact(n) { + if (n === null || n === undefined || Number.isNaN(n)) return '—'; + return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n); +} + +export function number(n) { + if (n === null || n === undefined || Number.isNaN(n)) return '—'; + return PLAIN.format(n); +} + +export function percent(fraction, digits = 1) { + if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—'; + return `${(fraction * 100).toFixed(digits)}%`; +} + +/** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */ +export function shortDay(day) { + const parsed = Date.parse(`${day}T00:00:00Z`); + if (!Number.isFinite(parsed)) return day; + return new Date(parsed).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} + +// --------------------------------------------------------------------------- +// Chart.js defaults +// --------------------------------------------------------------------------- + +/** + * Applied once, before any chart is built. Everything here is the recessive + * half of the design: hairline grid, muted axis text, no animation loud enough + * to notice. Text never wears a series colour — identity comes from the mark + * beside it, which is why the legend uses point-style swatches. + */ +export function applyChartDefaults(Chart) { + const { defaults } = Chart; + defaults.font.family = + "'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; + defaults.font.size = 12; + defaults.color = MUTED; + defaults.borderColor = GRID; + defaults.maintainAspectRatio = false; + defaults.animation.duration = 180; + + defaults.plugins.legend.position = 'bottom'; + defaults.plugins.legend.align = 'start'; + defaults.plugins.legend.labels.usePointStyle = true; + defaults.plugins.legend.labels.pointStyle = 'circle'; + defaults.plugins.legend.labels.boxWidth = 8; + defaults.plugins.legend.labels.boxHeight = 8; + defaults.plugins.legend.labels.padding = 14; + defaults.plugins.legend.labels.color = SECONDARY; + + defaults.plugins.tooltip.backgroundColor = INK; + defaults.plugins.tooltip.padding = 10; + defaults.plugins.tooltip.cornerRadius = 0; + defaults.plugins.tooltip.displayColors = true; + defaults.plugins.tooltip.usePointStyle = true; + defaults.plugins.tooltip.boxWidth = 8; + defaults.plugins.tooltip.boxHeight = 8; + + defaults.elements.line.borderWidth = 2; + defaults.elements.line.borderJoinStyle = 'round'; + defaults.elements.line.borderCapStyle = 'round'; + defaults.elements.line.tension = 0; + defaults.elements.point.hoverBorderWidth = 2; + defaults.elements.bar.borderRadius = 4; + defaults.elements.arc.borderColor = SURFACE; + // The 2px surface gap between touching fills — white doing the separating, + // rather than a stroke drawn around each mark. + defaults.elements.arc.borderWidth = 2; +} + +/** + * `ticks` is merged rather than replaced: spreading an override on top would + * silently drop the tick limit and hand back a y-axis labelled every 10%. + */ +const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } }); + +/** A value axis: hairline grid, clean ticks, always anchored at zero. */ +export function valueScale(extra = {}) { + return scale( + { + beginAtZero: true, + border: { color: AXIS }, + grid: { color: GRID, drawTicks: false }, + ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 }, + }, + extra, + ); +} + +/** A category or time axis: no grid at all, so the marks carry the chart. */ +export function categoryScale(extra = {}) { + return scale( + { + border: { color: AXIS }, + grid: { display: false }, + ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 }, + }, + extra, + ); +} + +/** + * Crosshair-style reading on anything plotted against days: hovering anywhere in + * a column reports every series at that day, so a 2px line never has to be hit + * dead-centre. + */ +export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' }; diff --git a/telemetry-dashboard/scripts/fixture.sql b/telemetry-dashboard/scripts/fixture.sql new file mode 100644 index 000000000..108df4436 --- /dev/null +++ b/telemetry-dashboard/scripts/fixture.sql @@ -0,0 +1,208 @@ +-- Seed data for the dashboard's local checks: 12 machines over 10 days +-- (2026-07-01 … 2026-07-10), small enough that every number on every panel can +-- be worked out by hand from the events below and checked against the API. +-- +-- npm run seed (writes the LOCAL .wrangler D1 — never the remote one) +-- +-- Only the raw `events` rows are hand-authored. `machine_days`, +-- `machine_first_seen` and the three `daily_*` rollups are DERIVED from them at +-- the bottom of this file by the same aggregations the writers use in +-- telemetry-worker/ (the ingest path and the nightly cron respectively), so the +-- fixture can never drift into a state production could not produce. +-- +-- The machines, and what each one does: +-- +-- id first os arch ver ci installs indexes on uninstalls +-- m01 07-01 darwin arm64 1.4.0 0 local 07-01, 07-02, 07-04 +-- m02 07-01 darwin arm64 1.4.0 0 global 07-01 +-- m03 07-01 linux x64 1.4.0 0 local 07-03 +-- m04 07-01 win32 x64 1.4.0 0 local never 07-06 +-- m05 07-02 darwin arm64 1.4.0 0 local 07-02 +-- m06 07-02 linux x64 1.4.1 0 local never 07-07 +-- m07 07-03 darwin x64 1.4.1 0 local 07-03 +-- m08 07-05 linux arm64 1.5.0 0 global 07-06 +-- m09 07-05 win32 x64 1.5.0 0 local 07-05, 07-07 +-- m10 07-08 darwin arm64 1.5.0 0 local 07-08 +-- m11 07-09 linux x64 1.5.0 0 local 07-10 +-- m12 07-09 linux x64 1.5.0 1 global 07-09 (CI runner) +-- +-- m04 and m06 never index: they are the two machines the activation funnel is +-- supposed to lose (12 installs → 10 activated → 83.3%). m12 is the one CI +-- machine, so "production users" is 11 where "active machines" is 12. + +DELETE FROM daily_dim_counts; +DELETE FROM daily_event_counts; +DELETE FROM daily_machines; +DELETE FROM machine_days; +DELETE FROM machine_first_seen; +DELETE FROM events; + +-- --------------------------------------------------------------------------- +-- install — 12, one per machine on its first day +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-01T09:00:00Z','2026-07-01T09:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","cursor"]}'), + ('2026-07-01T09:05:00Z','2026-07-01T09:05:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'), + ('2026-07-01T10:00:00Z','2026-07-01T10:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["codex"]}'), + ('2026-07-01T11:00:00Z','2026-07-01T11:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","opencode"]}'), + ('2026-07-02T09:00:00Z','2026-07-02T09:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-02T14:00:00Z','2026-07-02T14:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'), + ('2026-07-03T08:00:00Z','2026-07-03T08:00:00Z','2026-07-03','install','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"scope":"local","kind":"upgrade","targets":["claude"]}'), + ('2026-07-05T08:00:00Z','2026-07-05T08:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude","codex"]}'), + ('2026-07-05T09:00:00Z','2026-07-05T09:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-08T08:00:00Z','2026-07-08T08:00:00Z','2026-07-08','install','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'), + ('2026-07-09T08:00:00Z','2026-07-09T08:00:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'), + ('2026-07-09T08:30:00Z','2026-07-09T08:30:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'); + +-- --------------------------------------------------------------------------- +-- index — 13 runs +-- languages typescript 7 · javascript 2 · python 2 · go 2 · rust 2 · csharp 2 · java 1 (18 rows) +-- file_count_bucket <100 2 · 100-1k 5 · 1k-10k 4 · 10k+ 2 +-- duration_bucket <10s 5 · 10-60s 4 · 1-5m 2 · 5m+ 2 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-01T09:10:00Z','2026-07-01T09:10:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-01T09:20:00Z','2026-07-01T09:20:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'), + ('2026-07-02T10:00:00Z','2026-07-02T10:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'), + ('2026-07-02T11:00:00Z','2026-07-02T11:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"languages":["python"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'), + ('2026-07-03T09:00:00Z','2026-07-03T09:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"languages":["go"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-03T10:00:00Z','2026-07-03T10:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"languages":["typescript","rust"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'), + ('2026-07-04T10:00:00Z','2026-07-04T10:00:00Z','2026-07-04','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'), + ('2026-07-05T09:30:00Z','2026-07-05T09:30:00Z','2026-07-05','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'), + ('2026-07-06T09:00:00Z','2026-07-06T09:00:00Z','2026-07-06','index','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"languages":["rust","go"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'), + ('2026-07-07T09:00:00Z','2026-07-07T09:00:00Z','2026-07-07','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'), + ('2026-07-08T08:10:00Z','2026-07-08T08:10:00Z','2026-07-08','index','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'), + ('2026-07-09T09:00:00Z','2026-07-09T09:00:00Z','2026-07-09','index','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"languages":["java"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'), + ('2026-07-10T09:00:00Z','2026-07-10T09:00:00Z','2026-07-10','index','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"languages":["python","typescript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'); + +-- --------------------------------------------------------------------------- +-- uninstall — 2 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-06T12:00:00Z','2026-07-06T12:00:00Z','2026-07-06','uninstall','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"targets":["claude","opencode"]}'), + ('2026-07-07T12:00:00Z','2026-07-07T12:00:00Z','2026-07-07','uninstall','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"targets":["cursor"]}'); + +-- --------------------------------------------------------------------------- +-- usage_rollup — 5 rows, 85 calls (the `count` prop is summed, never the rows) +-- codegraph_explore 82 · index 3 | Claude Code 70 · Cursor 12 +-- --------------------------------------------------------------------------- +INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props) +VALUES + ('2026-07-03T02:00:00Z','2026-07-02T12:00:00Z','2026-07-02','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":40,"error_count":1,"client_name":"Claude Code"}'), + ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":25,"client_name":"Claude Code"}'), + ('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"kind":"cli_command","name":"index","count":3}'), + ('2026-07-07T02:00:00Z','2026-07-06T12:00:00Z','2026-07-06','usage_rollup','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Cursor"}'), + ('2026-07-11T02:00:00Z','2026-07-10T12:00:00Z','2026-07-10','usage_rollup','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":5,"client_name":"Claude Code"}'); + +-- --------------------------------------------------------------------------- +-- Derived: what the ingest worker writes on every batch +-- --------------------------------------------------------------------------- +-- prod is 0 only when EVERY event a machine sent that day carried ci = 1, which +-- is what makes m12 the only non-production machine-day. +INSERT INTO machine_days (machine_id, day, prod) +SELECT machine_id, day, max(CASE WHEN ci = 1 THEN 0 ELSE 1 END) FROM events GROUP BY machine_id, day; + +INSERT INTO machine_first_seen (machine_id, first_day) +SELECT machine_id, min(day) FROM events GROUP BY machine_id; + +-- --------------------------------------------------------------------------- +-- Derived: what the nightly cron writes +-- --------------------------------------------------------------------------- +-- These mirror ROLLUP_STATEMENTS in telemetry-worker/src/rollup.ts, with the +-- single-day filter dropped so one pass seeds the whole fixture range. + +INSERT INTO daily_machines (day, machines, prod_machines) +SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days GROUP BY day; + +INSERT INTO daily_event_counts (day, event, count, machines) +SELECT day, event, + CASE WHEN event = 'usage_rollup' + THEN sum(coalesce(json_extract(props, '$.count'), 0)) + ELSE count(*) END, + count(DISTINCT machine_id) + FROM events GROUP BY day, event; + +-- Envelope dimensions — carried by every event. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'os', CAST(os AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE os IS NOT NULL AND os <> '' GROUP BY day, event, os; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'arch', CAST(arch AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE arch IS NOT NULL AND arch <> '' GROUP BY day, event, arch; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'codegraph_version', CAST(codegraph_version AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE codegraph_version IS NOT NULL AND codegraph_version <> '' GROUP BY day, event, codegraph_version; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'node_major', CAST(node_major AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE node_major IS NOT NULL GROUP BY day, event, node_major; + +-- Event-specific scalar props. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'file_count_bucket', CAST(json_extract(props, '$.file_count_bucket') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'index' AND json_extract(props, '$.file_count_bucket') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.file_count_bucket'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'duration_bucket', CAST(json_extract(props, '$.duration_bucket') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'index' AND json_extract(props, '$.duration_bucket') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.duration_bucket'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'scope', CAST(json_extract(props, '$.scope') AS TEXT), count(*), count(DISTINCT machine_id) + FROM events WHERE event = 'install' AND json_extract(props, '$.scope') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.scope'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'kind', CAST(json_extract(props, '$.kind') AS TEXT), + CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END, + count(DISTINCT machine_id) + FROM events WHERE event IN ('install', 'usage_rollup') AND json_extract(props, '$.kind') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.kind'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'name', CAST(json_extract(props, '$.name') AS TEXT), + sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id) + FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.name'); + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'client_name', CAST(json_extract(props, '$.client_name') AS TEXT), + sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id) + FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.client_name') IS NOT NULL + GROUP BY day, event, json_extract(props, '$.client_name'); + +-- Array props — one row per element, so a TypeScript+Go repo counts under both. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT e.day, e.event, 'language', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id) + FROM events e, json_each(e.props, '$.languages') j + WHERE e.event = 'index' AND j.value <> '' + GROUP BY e.day, e.event, j.value; + +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT e.day, e.event, 'target', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id) + FROM events e, json_each(e.props, '$.targets') j + WHERE e.event IN ('install', 'uninstall') AND j.value <> '' + GROUP BY e.day, e.event, j.value; + +-- Errors per tool: count is errors, machines is the machines that saw one. +INSERT INTO daily_dim_counts (day, event, dim, value, count, machines) +SELECT day, event, 'name_error', CAST(json_extract(props, '$.name') AS TEXT), + sum(json_extract(props, '$.error_count')), count(DISTINCT machine_id) + FROM events + WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL + AND coalesce(json_extract(props, '$.error_count'), 0) > 0 + GROUP BY day, event, json_extract(props, '$.name'); diff --git a/telemetry-dashboard/scripts/render-check.mjs b/telemetry-dashboard/scripts/render-check.mjs new file mode 100644 index 000000000..9411bec54 --- /dev/null +++ b/telemetry-dashboard/scripts/render-check.mjs @@ -0,0 +1,465 @@ +#!/usr/bin/env node +/** + * Renders the dashboard in a real browser against the fixture and checks that + * every panel drew, and drew the numbers the API returned. + * + * smoke-api.sh proves the SQL; this proves the other half — that each panel is + * wired to the right endpoint and plots it without mangling it. It reads the + * Chart.js instance off each canvas and compares its dataset arrays against the + * same endpoint fetched straight from Node, so a panel pointed at the wrong dim + * fails here even though both halves are individually fine. + * + * node scripts/render-check.mjs (or: npm run smoke:render) + * + * Zero new dependencies: it drives whatever Chromium is already on the machine + * over the DevTools protocol (Node 22 has WebSocket built in). With no browser + * installed it SKIPS rather than fails — the shell smoke suites stay the + * portable floor, and this is the deeper check where a browser exists. + */ + +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const PORT = Number(process.env.DASH_PORT ?? 8790); +const BASE = `http://127.0.0.1:${PORT}`; + +/** The fixture's own window — see scripts/fixture.sql. */ +const FROM = '2026-07-01'; +const TO = '2026-07-10'; + +let pass = 0; +let fail = 0; + +const ok = (what) => { + console.log(` ok ${what}`); + pass++; +}; +const bad = (what, detail) => { + console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`); + fail++; +}; +const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail)); +const same = (what, expected, actual) => + check( + what, + JSON.stringify(expected) === JSON.stringify(actual), + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Finding a browser +// --------------------------------------------------------------------------- + +/** Expands one `*` in a path segment, newest match first. */ +function glob(pattern) { + const [head, ...rest] = pattern.split('*'); + const base = dirname(head); + const prefix = head.slice(base.length + 1); + if (!existsSync(base)) return []; + return readdirSync(base) + .filter((name) => name.startsWith(prefix)) + .sort() + .reverse() + .map((name) => join(base, name) + rest.join('*')); +} + +function findBrowser() { + const home = process.env.HOME ?? ''; + const candidates = [ + process.env.CHROME_BIN, + ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`), + ...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`), + ...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`), + ...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome', + ]; + return candidates.find((path) => path && existsSync(path)) ?? null; +} + +// --------------------------------------------------------------------------- +// A minimal DevTools-protocol client +// --------------------------------------------------------------------------- + +class CDP { + constructor(socket) { + this.socket = socket; + this.nextId = 1; + this.pending = new Map(); + this.events = []; + socket.addEventListener('message', (event) => { + const message = JSON.parse(event.data); + if (message.id !== undefined) { + const waiter = this.pending.get(message.id); + if (!waiter) return; + this.pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result); + } else { + this.events.push(message); + } + }); + } + + static async connect(url) { + const socket = new WebSocket(url); + await new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true }); + }); + return new CDP(socket); + } + + send(method, params = {}, sessionId) { + const id = this.nextId++; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params })); + }); + } + + /** Runs an expression in the page and returns its value, awaiting promises. */ + async evaluate(sessionId, expression) { + const result = await this.send( + 'Runtime.evaluate', + { expression, returnByValue: true, awaitPromise: true }, + sessionId, + ); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.exception?.description ?? 'page threw'); + } + return result.result.value; + } +} + +// --------------------------------------------------------------------------- +// The page probe +// --------------------------------------------------------------------------- + +/** + * Runs inside the page. Reads what each panel actually rendered — including the + * live Chart.js instance behind each canvas — rather than trusting that a + * fetch resolved. + */ +const PROBE = `(() => { + const panels = [...document.querySelectorAll('[data-panel]')].map((section) => { + const canvas = section.querySelector('canvas'); + const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null; + return { + id: section.dataset.panel, + state: section.dataset.state, + stale: section.dataset.stale, + title: section.querySelector('h2').textContent, + figure: section.querySelector('[data-role="figure"]').textContent, + note: section.querySelector('.panel-note')?.textContent ?? '', + message: section.querySelector('[data-role="state"]').textContent, + stat: section.querySelector('.stat-value')?.textContent ?? null, + funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent), + funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width), + chart: chart && { + type: chart.config.type, + labels: chart.data.labels, + datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })), + legend: chart.options.plugins?.legend?.display !== false, + }, + tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length, + tableCols: section.querySelectorAll('[data-role="table"] thead th').length, + tableHidden: section.querySelector('[data-role="table"]').hidden, + }; + }); + return { + ready: document.body.dataset.ready === 'true', + range: document.getElementById('range-summary').textContent, + dataThrough: document.getElementById('data-through').textContent, + refreshed: document.getElementById('refreshed-at').textContent, + selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null, + panels, + }; +})()`; + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +const children = []; +let profileDir = null; + +function cleanup() { + for (const child of children) { + try { + child.kill('SIGTERM'); + } catch { + /* already gone */ + } + } + if (profileDir) rmSync(profileDir, { recursive: true, force: true }); +} +process.on('exit', cleanup); +process.on('SIGINT', () => process.exit(130)); + +function run(command, args, options = {}) { + const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options }); + children.push(child); + return child; +} + +async function waitFor(what, probe, attempts = 90) { + for (let i = 0; i < attempts; i++) { + try { + if (await probe()) return true; + } catch { + /* not up yet */ + } + await sleep(1000); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function main() { + const browserPath = findBrowser(); + if (!browserPath) { + console.log('render-check: no Chromium found — skipping.'); + console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.'); + return 0; + } + console.log(`Browser: ${browserPath}`); + + console.log('Seeding the local D1 fixture…'); + const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' }); + const seeded = await new Promise((resolve) => seed.on('exit', resolve)); + if (seeded !== 0) throw new Error('seeding failed'); + + console.log(`Starting wrangler dev on :${PORT}…`); + run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']); + await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok); + + const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1]; + if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars'); + const login = await fetch(`${BASE}/login`, { + method: 'POST', + body: new URLSearchParams({ password }), + redirect: 'manual', + }); + const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session=')); + if (!cookie) throw new Error('login did not set a session cookie'); + const [name, value] = cookie.split(';')[0].split('='); + + profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-')); + // chrome-headless-shell is headless by construction and rejects the flag; + // a full Chrome needs it. + const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new']; + run(browserPath, [ + ...headlessFlag, + '--disable-gpu', + '--no-first-run', + '--no-default-browser-check', + '--remote-debugging-port=0', + `--user-data-dir=${profileDir}`, + 'about:blank', + ]); + + let devtoolsPort = null; + await waitFor('the browser', () => { + const portFile = join(profileDir, 'DevToolsActivePort'); + if (!existsSync(portFile)) return false; + devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + return Number.isFinite(devtoolsPort) && devtoolsPort > 0; + }, 30); + + const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json(); + const cdp = await CDP.connect(version.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + + await cdp.send('Page.enable', {}, sessionId); + await cdp.send('Runtime.enable', {}, sessionId); + await cdp.send('Log.enable', {}, sessionId); + await cdp.send('Network.enable', {}, sessionId); + await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId); + + await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId); + await waitFor('the dashboard to finish rendering', async () => { + const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"'); + return view === true; + }, 60); + + let view = await cdp.evaluate(sessionId, PROBE); + + // -- what loaded --------------------------------------------------------- + console.log('\nThe page renders'); + // The very same registry the page just rendered from, imported here so the + // expectations cannot drift from the panels under test. + const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href); + check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`); + const broken = view.panels.filter((p) => p.state !== 'ready'); + check( + 'every panel reached its ready state', + broken.length === 0, + broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '), + ); + check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset); + check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range); + check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough); + check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed); + + // A CSP violation surfaces here as a `security` log entry, which is the point + // of the check: the page must work under `script-src 'self'` with no inline + // styles at all. The favicon 404 is expected — there isn't one — and is the + // only network noise allowed through. + const errors = cdp.events.filter( + (e) => + (e.method === 'Log.entryAdded' && + e.params.entry.level === 'error' && + !/favicon/.test(e.params.entry.url ?? '')) || + e.method === 'Runtime.exceptionThrown', + ); + check( + 'no console errors — the strict CSP allows everything the page needs', + errors.length === 0, + errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '), + ); + + // -- the range picker really re-queries ---------------------------------- + console.log('\nChanging the range re-queries every panel'); + await cdp.evaluate( + sessionId, + `document.body.dataset.ready = ""; + [...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`, + ); + await waitFor('the 7-day render', async () => + (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true, + ); + view = await cdp.evaluate(sessionId, PROBE); + const weekly = view.panels.find((p) => p.id === 'daily-production-users'); + check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`); + check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset); + check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready')); + + console.log('\nA custom range works the same way'); + await cdp.evaluate( + sessionId, + `document.body.dataset.ready = ""; + document.querySelector('[data-role="custom-from"]').value = "${FROM}"; + document.querySelector('[data-role="custom-to"]').value = "${TO}"; + document.querySelector('[data-role="custom-apply"]').click();`, + ); + await waitFor('the custom-range render', async () => + (await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true, + ); + view = await cdp.evaluate(sessionId, PROBE); + check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10); + check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset); + + // -- every panel plots what the API returned ------------------------------ + console.log('\nEvery panel plots the API’s own numbers'); + const query = `from=${FROM}&to=${TO}`; + const fetched = new Map(); + const apiGet = async (path) => { + if (!fetched.has(path)) { + fetched.set( + path, + fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()), + ); + } + return fetched.get(path); + }; + + for (const panel of PANELS) { + const rendered = view.panels.find((p) => p.id === panel.id); + const data = await apiGet(panel.source(query)); + + if (panel.kind === 'chart') { + const plotted = rendered.chart?.datasets.map((d) => d.data); + same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted); + // A legend is owed wherever colour carries identity: any multi-series + // chart, and every pie (whose slices are identities inside one dataset). + // A single line needs none — the panel title already names it. + const owed = rendered.chart.type === 'pie' || data.datasets.length > 1; + check( + `${panel.id}: a legend exactly where colour carries identity`, + rendered.chart.legend === owed, + `legend ${rendered.chart.legend}, expected ${owed}`, + ); + } else if (panel.kind === 'stat') { + same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat); + } else if (panel.kind === 'funnel') { + same( + `${panel.id}: shows both funnel stages`, + panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')), + rendered.funnelValues, + ); + } + + const table = panel.table(data); + check( + `${panel.id}: the table twin carries every row`, + rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length, + `${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`, + ); + } + + // -- a few numbers checked against the fixture by hand -------------------- + console.log('\nSpot checks against the fixture, worked out by hand'); + const byId = Object.fromEntries(view.panels.map((p) => [p.id, p])); + same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat); + same('installs is 12', '12', byId['installs'].stat); + same('uninstalls is 2', '2', byId['uninstalls'].stat); + same('indexing runs is 13', '13', byId['indexing-runs'].stat); + same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues); + const widths = byId['activation-funnel'].funnelWidths; + check( + '…and draws the drop as a shorter bar', + widths[0] === '100%' && widths[1].startsWith('83.3'), + widths.join(' / '), + ); + same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels); + same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data)); + check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure); + same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels); + same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]); + check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100); + + // Colour, spacing and label collisions are not things an assertion catches. + // RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it. + if (process.env.RENDER_SHOT) { + await cdp.send( + 'Emulation.setDeviceMetricsOverride', + { width: 1440, height: 900, deviceScaleFactor: 2, mobile: false }, + sessionId, + ); + await sleep(500); + const shot = await cdp.send( + 'Page.captureScreenshot', + { format: 'png', captureBeyondViewport: true }, + sessionId, + ); + writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64')); + console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`); + } + + console.log('\nPanel copy follows the house rules'); + const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title)); + check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', ')); + check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20)); + check('tables start closed', view.panels.every((p) => p.tableHidden)); + + return fail; +} + +try { + const failures = await main(); + console.log(`\n${pass} passed, ${fail} failed`); + process.exit(failures === 0 ? 0 : 1); +} catch (err) { + console.error(`\nrender-check: ${err.message}`); + process.exit(1); +} diff --git a/telemetry-dashboard/scripts/seed-fixture.sh b/telemetry-dashboard/scripts/seed-fixture.sh new file mode 100755 index 000000000..88c8b1e27 --- /dev/null +++ b/telemetry-dashboard/scripts/seed-fixture.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Loads scripts/fixture.sql into the LOCAL .wrangler D1 (never the remote one: +# --local is on every command here, and nothing in this repo writes production). +# +# The schema comes from the writer, telemetry-worker/migrations/, because that +# is where it belongs — D1 is read-only from this worker. +# +# ./scripts/seed-fixture.sh (or: npm run seed) +set -uo pipefail + +cd "$(dirname "$0")/.." + +DB=codegraph-telemetry +MIGRATION=../telemetry-worker/migrations/0001_init.sql + +if [[ ! -f "$MIGRATION" ]]; then + echo "seed: cannot find $MIGRATION — run this from a full checkout" >&2 + exit 1 +fi + +# The migration is plain CREATE TABLE, so a second run fails on "table already +# exists". That is the expected steady state here, hence the swallowed output — +# the fixture load below is the step whose failure actually matters. +npx wrangler d1 execute "$DB" --local --file="$MIGRATION" >/dev/null 2>&1 + +if ! npx wrangler d1 execute "$DB" --local --file=scripts/fixture.sql >/dev/null; then + echo "seed: loading scripts/fixture.sql failed" >&2 + exit 1 +fi + +echo "seed: fixture loaded into the local $DB (12 machines, 2026-07-01 … 2026-07-10)" diff --git a/telemetry-dashboard/scripts/smoke-api.sh b/telemetry-dashboard/scripts/smoke-api.sh new file mode 100755 index 000000000..964c1b681 --- /dev/null +++ b/telemetry-dashboard/scripts/smoke-api.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# End-to-end check of the chart API against the committed fixture. +# +# Every expected number below is worked out by hand from scripts/fixture.sql — +# the header comment there lists all twelve machines and what each one does — so +# a failure here means the SQL changed its mind, not that a golden file drifted. +# +# ./scripts/smoke-api.sh (or: npm run smoke:api) +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Deliberately NOT $PORT — see smoke-auth.sh. +DASH_PORT="${DASH_PORT:-8789}" +BASE="http://127.0.0.1:${DASH_PORT}" +PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)" +JAR="$(mktemp -t cg-api-jar)" +LOG="$(mktemp -t cg-api-log)" +PASS=0 +FAIL=0 + +# The fixture's own window. Every assertion is scoped to it, so a later fixture +# row outside these days cannot silently change an expected number. +FROM=2026-07-01 +TO=2026-07-10 +RANGE="from=$FROM&to=$TO" + +cleanup() { + [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null + rm -f "$JAR" "$LOG" +} +trap cleanup EXIT + +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } +get() { curl -s -b "$JAR" "$BASE$1"; } + +# Resolves a dotted path through the JSON. Numeric segments index arrays, so +# `datasets.0.data` works. Node rather than jq: this is a Node project, jq is not. +jget() { + node -e ' + let v = JSON.parse(process.argv[1]); + for (const key of process.argv[2].split(".")) v = v?.[key]; + console.log(v === undefined ? "" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)); + ' "$1" "$2" +} + +check() { # check + if [[ "$2" == "$3" ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +field() { # field + check "$1" "$3" "$(jget "$4" "$2")" +} + +echo "Seeding the local D1 fixture…" +./scripts/seed-fixture.sh || exit 1 + +echo "Starting wrangler dev on :${DASH_PORT}…" +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +READY="" +for _ in $(seq 1 90); do + if [[ "$(curl -s "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi + sleep 1 +done +if [[ -z "$READY" ]]; then + echo "wrangler dev never came up on :${DASH_PORT} — log follows" + cat "$LOG" + exit 1 +fi + +echo +echo "The gate still holds on every new endpoint" +for path in summary meta timeseries breakdown activation retention; do + check "GET /api/$path without a cookie → 401" 401 "$(status "$BASE/api/$path")" +done + +curl -s -o /dev/null -c "$JAR" -X POST -d "password=$PASSWORD" "$BASE/login" +check "signed in" 200 "$(status -b "$JAR" "$BASE/api/session")" + +echo +echo "Caching" +check "chart data is privately cacheable" "private, max-age=300" \ + "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/summary" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')" +check "health stays uncached" "no-store" \ + "$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/health" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')" + +echo +echo "/api/meta — what the range picker anchors on" +META="$(get "/api/meta")" +field "latest day" latest_day 2026-07-10 "$META" +field "earliest day" earliest_day 2026-07-01 "$META" +field "raw events start" earliest_raw_day 2026-07-01 "$META" +field "retention window" retention_days 14 "$META" + +echo +echo "/api/summary — the big numbers (12 machines, one of them CI)" +SUMMARY="$(get "/api/summary?$RANGE")" +field "production users (m12 is CI)" production_users 11 "$SUMMARY" +field "active machines" active_machines 12 "$SUMMARY" +field "new machines" new_machines 12 "$SUMMARY" +field "installs" installs 12 "$SUMMARY" +field "uninstalls" uninstalls 2 "$SUMMARY" +field "indexing runs" index_runs 13 "$SUMMARY" +field "tool calls (SUM of count)" tool_calls 85 "$SUMMARY" +field "range echoed back" range.days 10 "$SUMMARY" + +echo +echo "/api/timeseries — one dense point per day, zeros where nothing happened" +TS="$(get "/api/timeseries?metric=installs_uninstalls&$RANGE")" +field "10 labels" labels.0 2026-07-01 "$TS" +field "installs" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS" +field "uninstalls" datasets.1.data '[0,0,0,0,0,1,1,0,0,0]' "$TS" +field "legend labels" datasets.1.label Uninstalls "$TS" + +TS="$(get "/api/timeseries?metric=new_installs&$RANGE")" +field "new installs by first-seen day" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS" + +TS="$(get "/api/timeseries?metric=production_users&$RANGE")" +field "daily production users" datasets.0.data '[4,3,4,1,2,3,2,1,1,1]' "$TS" + +TS="$(get "/api/timeseries?metric=indexing_activity&$RANGE")" +field "indexing runs" datasets.0.data '[2,2,2,1,1,1,1,1,1,1]' "$TS" +field "machines indexing" datasets.1.data '[2,2,2,1,1,1,1,1,1,1]' "$TS" + +TS="$(get "/api/timeseries?metric=tool_calls&$RANGE")" +field "calls per day" datasets.0.data '[0,40,28,0,0,12,0,0,0,5]' "$TS" +field "machines per day" datasets.1.data '[0,1,2,0,0,1,0,0,0,1]' "$TS" + +TS="$(get "/api/timeseries?metric=duration_buckets&$RANGE")" +field "bucket order is the scale" datasets.0.label '<10s' "$TS" +field "…and ends at the longest" datasets.3.label '5m+' "$TS" +field "<10s over time" datasets.0.data '[2,0,1,1,0,0,0,1,0,0]' "$TS" +field "10-60s over time" datasets.1.data '[0,2,0,0,0,0,1,0,0,1]' "$TS" +field "1-5m over time" datasets.2.data '[0,0,0,0,1,1,0,0,0,0]' "$TS" +field "5m+ over time" datasets.3.data '[0,0,1,0,0,0,0,0,1,0]' "$TS" + +echo +echo "/api/breakdown — bars and pies" +# machine-days, taking the largest per-event count per day so one machine's +# install + index + usage_rollup on one day is not counted three times. +BD="$(get "/api/breakdown?dim=os&$RANGE")" +field "os labels" labels '["linux","darwin","win32"]' "$BD" +field "os machine-days" datasets.0.data '[9,8,4]' "$BD" +field "os metric named" datasets.0.label 'Machine-days' "$BD" +field "os total" total 21 "$BD" + +BD="$(get "/api/breakdown?dim=os&metric=count&$RANGE")" +field "os by events sums every event" total 112 "$BD" + +BD="$(get "/api/breakdown?dim=language&$RANGE")" +field "languages, most-indexed first" labels '["typescript","csharp","go","javascript","python","rust","java"]' "$BD" +field "language counts" datasets.0.data '[7,2,2,2,2,2,1]' "$BD" +field "language rows total" total 18 "$BD" + +BD="$(get "/api/breakdown?dim=file_count_bucket&$RANGE")" +field "codebase size keeps bucket order" labels '["<100","100-1k","1k-10k","10k+"]' "$BD" +field "codebase size counts" datasets.0.data '[2,5,4,2]' "$BD" + +BD="$(get "/api/breakdown?dim=duration_bucket&$RANGE")" +field "run length keeps bucket order" labels '["<10s","10-60s","1-5m","5m+"]' "$BD" +field "run length counts" datasets.0.data '[5,4,2,2]' "$BD" +field "run length total = index runs" total 13 "$BD" + +BD="$(get "/api/breakdown?dim=target&$RANGE")" +field "agent targets are the installs" event install "$BD" +field "agent target labels" labels '["claude","cursor","codex","opencode"]' "$BD" +field "agent target counts" datasets.0.data '[9,3,2,1]' "$BD" + +BD="$(get "/api/breakdown?dim=codegraph_version&$RANGE")" +field "versions sort newest first" labels '["1.5.0","1.4.1","1.4.0"]' "$BD" +field "version machine-days" datasets.0.data '[8,3,10]' "$BD" + +BD="$(get "/api/breakdown?dim=name&$RANGE")" +field "tool names by call volume" labels '["codegraph_explore","index"]' "$BD" +field "tool call counts" datasets.0.data '[82,3]' "$BD" + +BD="$(get "/api/breakdown?dim=client_name&$RANGE")" +field "agents by call volume" labels '["Claude Code","Cursor"]' "$BD" +field "agent call counts" datasets.0.data '[70,12]' "$BD" + +BD="$(get "/api/breakdown?dim=kind&$RANGE")" +field "install kinds" labels '["fresh","upgrade"]' "$BD" +field "install kind counts" datasets.0.data '[11,1]' "$BD" + +BD="$(get "/api/breakdown?dim=scope&$RANGE")" +field "install scopes" datasets.0.data '[9,3]' "$BD" + +BD="$(get "/api/breakdown?dim=name_error&$RANGE")" +field "errors by tool" datasets.0.data '[1]' "$BD" + +BD="$(get "/api/breakdown?dim=language&limit=2&$RANGE")" +field "the tail folds into Other, never truncates" labels '["typescript","csharp","Other"]' "$BD" +field "Other keeps the total honest" total 18 "$BD" +field "truncation is declared" truncated true "$BD" + +echo +echo "/api/activation — install → first index within 7 days" +ACT="$(get "/api/activation?$RANGE")" +field "cohort is every machine first seen" installs 12 "$ACT" +field "m04 and m06 never indexed" activated 10 "$ACT" +field "…so two dropped" dropped 2 "$ACT" +field "window" window_days 7 "$ACT" +field "daily rate, null where no cohort" datasets.0.data '[75,50,100,null,100,null,null,100,100,null]' "$ACT" +field "recent cohorts flagged incomplete" incomplete_from 2026-07-04 "$ACT" +field "…and the completed ones are not" rows.2.complete true "$ACT" +field "…while the last week is" rows.8.complete false "$ACT" + +# Narrowing the window drops m03 alone: it installed on 07-01 and did not index +# until 07-03. Everyone else who ever indexed did it on day 0 or day 1. +ACT="$(get "/api/activation?window=1&$RANGE")" +field "a 1-day window converts fewer" activated 9 "$ACT" + +echo +echo "/api/retention — day 0–14, denominator per day" +RET="$(get "/api/retention?$RANGE")" +field "cohort size" cohort 12 "$RET" +field "15 points" labels.14 'Day 14' "$RET" +# Day 2 divides by 10, not 12: m11/m12 arrived on 07-09 and cannot have a day-2 +# data point yet. Day 10+ is null — nobody in the cohort is old enough at all. +field "retention curve" datasets.0.data \ + '[100,41.7,30,11.1,0,22.2,0,0,0,0,null,null,null,null,null]' "$RET" +field "day 2 eligible excludes the newest cohorts" rows.2.eligible 10 "$RET" +field "day 10 has nobody old enough" rows.10.eligible 0 "$RET" + +echo +echo "Bad input is rejected, never guessed at" +check "unknown dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=machine_id")" +check "missing dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown")" +check "unknown metric → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&metric=secrets")" +check "unknown series → 400" 400 "$(status -b "$JAR" "$BASE/api/timeseries?metric=everything")" +check "limit out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&limit=0")" +check "impossible date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-02-31&to=2026-07-10")" +check "malformed date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=yesterday")" +check "backwards range → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-07-10&to=2026-07-01")" +check "window out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/activation?window=99")" +check "unknown endpoint → 404" 404 "$(status -b "$JAR" "$BASE/api/everything")" +check "event name is a closed shape → 400" 400 \ + "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&event=install%27%20OR%201=1")" + +CLAMPED="$(get "/api/breakdown?dim=os&from=2019-01-01&to=$TO")" +field "a decade-wide range clamps to a year" range.days 366 "$CLAMPED" +field "…and says so" range.clamped true "$CLAMPED" +field "…kept against the recent end" range.from 2025-07-10 "$CLAMPED" + +echo +echo "An empty range renders as empty, not as an error" +EMPTY="$(get "/api/summary?from=2025-01-01&to=2025-01-07")" +field "no machines" production_users 0 "$EMPTY" +field "no installs" installs 0 "$EMPTY" +EMPTY="$(get "/api/breakdown?dim=os&from=2025-01-01&to=2025-01-07")" +field "no bars" labels '[]' "$EMPTY" +EMPTY="$(get "/api/timeseries?metric=production_users&from=2025-01-01&to=2025-01-03")" +field "still a dense axis" datasets.0.data '[0,0,0]' "$EMPTY" + +echo +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/telemetry-dashboard/scripts/smoke-auth.sh b/telemetry-dashboard/scripts/smoke-auth.sh new file mode 100755 index 000000000..e8f0a8c0e --- /dev/null +++ b/telemetry-dashboard/scripts/smoke-auth.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# End-to-end check of the auth gate against a local `wrangler dev`. +# +# Verifies the acceptance criteria for the gate: unauthenticated requests reach +# nothing (pages, API, or static assets), a valid cookie reaches everything, and +# a tampered cookie is rejected. Run it after touching src/auth.ts or the route +# table in src/index.ts. +# +# ./scripts/smoke-auth.sh +set -uo pipefail + +cd "$(dirname "$0")/.." + +# Deliberately NOT $PORT: that is commonly already set to some other local dev +# server, and the whole suite would then silently test the wrong app. +DASH_PORT="${DASH_PORT:-8788}" +BASE="http://127.0.0.1:${DASH_PORT}" +PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)" +JAR="$(mktemp -t cg-dash-jar)" +LOG="$(mktemp -t cg-dash-log)" +DEV_VARS_BACKUP="$(mktemp -t cg-dash-vars)" +PASS=0 +FAIL=0 + +cleanup() { + [[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null + # The rotation phase rewrites .dev.vars; always put the original back. + [[ -s "$DEV_VARS_BACKUP" ]] && cp "$DEV_VARS_BACKUP" .dev.vars + rm -f "$JAR" "$LOG" "$DEV_VARS_BACKUP" +} +trap cleanup EXIT + +# `curl -o /dev/null -w '%{http_code}'` plus the headers we care about. +status() { curl -s -o /dev/null -w '%{http_code}' "$@"; } +body() { curl -s "$@"; } + +check() { # check + if [[ "$2" == "$3" ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +contains() { # contains + if [[ "$3" == *"$2"* ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (missing %q in %.200q…)\n' "$1" "$2" "$3" + FAIL=$((FAIL + 1)) + fi +} + +lacks() { # lacks + if [[ "$3" != *"$2"* ]]; then + printf ' ok %s\n' "$1" + PASS=$((PASS + 1)) + else + printf ' FAIL %s (found %q)\n' "$1" "$2" + FAIL=$((FAIL + 1)) + fi +} + +echo "Seeding local D1 from the ingest worker's migration…" +npx wrangler d1 execute codegraph-telemetry --local \ + --file=../telemetry-worker/migrations/0001_init.sql >/dev/null 2>&1 + +echo "Starting wrangler dev on :${DASH_PORT}…" +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +READY="" +for _ in $(seq 1 90); do + if [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi + sleep 1 +done +if [[ -z "$READY" ]]; then + echo "wrangler dev never came up on :${DASH_PORT} — log follows" + cat "$LOG" + exit 1 +fi + +echo +echo "Unauthenticated — nothing but the login page and robots.txt" +check "GET / → 302 to login" 302 "$(status "$BASE/")" +check "GET /index.html → 302 to login" 302 "$(status "$BASE/index.html")" +check "GET /styles.css → 302 to login" 302 "$(status "$BASE/styles.css")" +check "GET /app.js → 302 to login" 302 "$(status "$BASE/app.js")" +check "GET /vendor/chart → 302 to login" 302 "$(status "$BASE/vendor/chart.umd.js")" +check "GET /api/health → 401" 401 "$(status "$BASE/api/health")" +check "GET /api/session → 401" 401 "$(status "$BASE/api/session")" +check "GET /api/anything → 401" 401 "$(status "$BASE/api/whatever")" +check "GET /login → 200" 200 "$(status "$BASE/login")" +check "GET /robots.txt → 200" 200 "$(status "$BASE/robots.txt")" +contains "no data leaks in the 401 body" '"unauthorized"' "$(body "$BASE/api/health")" + +echo +echo "Login page" +LOGIN_HTML="$(body "$BASE/login")" +contains "sentence-case heading" "codegraph telemetry" "$LOGIN_HTML" +contains "sentence-case label" ">Password<" "$LOGIN_HTML" +contains "sentence-case button" ">Sign in<" "$LOGIN_HTML" +lacks "no uppercased labels" "uppercase" "$LOGIN_HTML" +lacks "no tracked-out labels" "letter-spacing" "$LOGIN_HTML" +contains "label is normal size" "font-size: 16px" "$LOGIN_HTML" +check "open redirect refused" "/" \ + "$(body "$BASE/login?next=%2F%2Fevil.example" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')" +check "same-origin next kept" "/api/health" \ + "$(body "$BASE/login?next=%2Fapi%2Fhealth" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')" + +echo +echo "Sign-in" +check "wrong password → 401" 401 \ + "$(status -X POST "$BASE/login" -d "password=definitely-not-it" -d "next=/")" +check "wrong password sets no cookie" "" \ + "$(curl -s -D - -o /dev/null -X POST "$BASE/login" -d "password=nope" | grep -ci 'set-cookie' | sed 's/^0$//')" +check "empty password → 400" 400 "$(status -X POST "$BASE/login" -d "password=")" +check "cross-origin post → 400" 400 \ + "$(status -X POST "$BASE/login" -H 'Origin: https://evil.example' -d "password=${PASSWORD}")" +# One sign-in, then every cookie assertion reads the captured headers. Doing a +# fresh POST per assertion would burn the login rate limit and 429 halfway down. +SIGNIN="$(curl -s -D - -o /dev/null -c "$JAR" -X POST "$BASE/login" -d "password=${PASSWORD}" -d "next=/")" +check "correct password → 302" "302" "$(printf '%s' "$SIGNIN" | head -1 | awk '{print $2}')" +contains "cookie is HttpOnly" "HttpOnly" "$SIGNIN" +contains "cookie is Secure" "Secure" "$SIGNIN" +contains "cookie is SameSite=Lax" "SameSite=Lax" "$SIGNIN" +contains "cookie is ~1 year" "Max-Age=31536000" "$SIGNIN" +contains "cookie is site-wide" "Path=/" "$SIGNIN" + +COOKIE="$(grep cg_admin_session "$JAR" | awk '{print $NF}')" +PAYLOAD="${COOKIE%%.*}" +SIG="${COOKIE#*.}" + +# A persistent cookie carries a real expiry in the jar; a session cookie (gone +# on browser restart) carries 0. This is the "survives a restart" criterion. +JAR_EXPIRY="$(grep cg_admin_session "$JAR" | awk '{print $5}')" +if [[ "$JAR_EXPIRY" -gt "$(( $(date +%s) + 300 * 86400 ))" ]]; then + check "cookie persists across browser restarts" "persistent" "persistent" +else + check "cookie persists across browser restarts" "persistent" "session-only (expiry ${JAR_EXPIRY})" +fi + +echo +echo "Authenticated — the whole app" +check "GET / → 200" 200 "$(status -b "$JAR" "$BASE/")" +check "GET /styles.css → 200" 200 "$(status -b "$JAR" "$BASE/styles.css")" +check "GET /app.js → 200" 200 "$(status -b "$JAR" "$BASE/app.js")" +check "GET /vendor/chart→ 200" 200 "$(status -b "$JAR" "$BASE/vendor/chart.umd.js")" +check "GET /api/session → 200" 200 "$(status -b "$JAR" "$BASE/api/session")" +check "GET /api/health → 200" 200 "$(status -b "$JAR" "$BASE/api/health")" +contains "health reads D1" '"ok":true' "$(body -b "$JAR" "$BASE/api/health")" +check "GET /login while signed in → 302" 302 "$(status -b "$JAR" "$BASE/login")" +check "unknown API route → 404" 404 "$(status -b "$JAR" "$BASE/api/nope")" +check "POST to an API route → 405" 405 "$(status -b "$JAR" -X POST "$BASE/api/health")" + +echo +echo "Tampering" +# Mutate the FIRST signature character, not the last: base64url's final +# character of a 32-byte tag carries only 4 significant bits, so flipping it is +# sometimes a no-op on the decoded bytes and the test would pass vacuously. +FLIPPED="${PAYLOAD}.$([[ "${SIG:0:1}" == 'A' ]] && echo B || echo A)${SIG:1}" +check "flipped signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/api/health")" +check "truncated signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}.${SIG:0:40}" "$BASE/api/health")" +check "swapped payload → 401" 401 \ + "$(status -H "Cookie: cg_admin_session=$(printf '%s' '{"v":1,"iat":0,"exp":9999999999,"pw":"x"}' | base64 | tr -d '=' | tr '+/' '-_').${SIG}" "$BASE/api/health")" +check "no signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}" "$BASE/api/health")" +check "garbage cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=not-a-token' "$BASE/api/health")" +check "empty cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=' "$BASE/api/health")" +check "tampered cookie on a page → 302 to login" 302 \ + "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/")" + +echo +echo "Sign-out" +check "POST /logout → 302" 302 "$(status -X POST "$BASE/logout")" +contains "logout clears the cookie" "Max-Age=0" \ + "$(curl -s -D - -o /dev/null -X POST "$BASE/logout")" +check "GET /logout → 405" 405 "$(status "$BASE/logout")" + +echo +echo "Rate limiting (6 attempts in a minute; the 6th should be capped)" +LAST="" +for _ in 1 2 3 4 5 6 7; do + LAST="$(status -X POST "$BASE/login" -d 'password=guess')" +done +check "brute force capped → 429" 429 "$LAST" + +echo +echo "Password rotation (restarting with a different ADMIN_PASSWORD)" +cp .dev.vars "$DEV_VARS_BACKUP" +sed 's/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD="rotated-password"/' "$DEV_VARS_BACKUP" >.dev.vars +kill "$DEV_PID" 2>/dev/null +wait "$DEV_PID" 2>/dev/null +npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 & +DEV_PID=$! +for _ in $(seq 1 90); do + [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]] && break + sleep 1 +done +check "cookie from the old password → 401" 401 \ + "$(status -H "Cookie: cg_admin_session=${COOKIE}" "$BASE/api/health")" +check "old password no longer signs in → 401" 401 \ + "$(status -X POST "$BASE/login" -d "password=${PASSWORD}")" +check "new password signs in → 302" 302 \ + "$(status -X POST "$BASE/login" -d "password=rotated-password")" + +echo +printf '%s\n' "-----" +printf '%d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]] diff --git a/telemetry-dashboard/scripts/vendor-assets.mjs b/telemetry-dashboard/scripts/vendor-assets.mjs new file mode 100644 index 000000000..4d6378e0b --- /dev/null +++ b/telemetry-dashboard/scripts/vendor-assets.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Copies third-party browser libraries out of node_modules into public/vendor/. + * + * Workers Static Assets are served verbatim — nothing in public/ goes through a + * bundler — so a library from npm has to be physically present there. Keeping + * it a copy step (rather than a checked-in blob or a CDN