From 5dee94d5b17d67734110b956ada0c62131575e67 Mon Sep 17 00:00:00 2001 From: Ali Sher Date: Tue, 18 Aug 2026 11:33:37 +0500 Subject: [PATCH 1/4] feat(cache): incremental parse cache for analyze/graph/diff/doctor Repeated runs only re-parse files whose content changed; everything else is served from a cached surface under .ripple/cache, keeping the stable JSON contract byte-identical while large-codebase runs get faster. - buildGraphFromParsed extracted so the pipeline can reuse parsed surfaces - ts-morph project drops stale SourceFiles before re-parsing a changed file - parseSourceFile now records parseError for extractor throws too, so broken files lower confidence instead of aborting the run - discovery hard-excludes .ripple so the cache is never scanned - RIPPLE_NO_CACHE=1 forces a cold run (for benchmarks); .ripple gitignored --- .gitignore | 2 + CHANGELOG.md | 13 ++ src/cache/parsed.ts | 208 ++++++++++++++++++++++++ src/commands/pipeline.ts | 20 ++- src/graph/build.ts | 13 +- src/parser/parse.ts | 25 ++- src/scanner/discover.ts | 2 +- tests/unit/cache/parsed.test.ts | 277 ++++++++++++++++++++++++++++++++ 8 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 src/cache/parsed.ts create mode 100644 tests/unit/cache/parsed.test.ts diff --git a/.gitignore b/.gitignore index 2662bd9..a90c747 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ coverage/ .DS_Store *.tsbuildinfo *.docx + +.ripple/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c2230..59df241 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to Ripple are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Incremental parse cache (`.ripple/cache/`). Repeated `analyze`, `graph`, + `diff` and `doctor` runs only re-parse the files whose content changed — + everything else is served from the cached surface, so the stable JSON + contract stays byte-identical while large-codebase runs get faster. + Disable with `RIPPLE_NO_CACHE=1`. The cache is never scanned by discovery. +- Parsing is now fully error-tolerant: a broken file that trips an extractor + records a `parseError` instead of aborting the run, matching the documented + "lower confidence instead of failing" behavior. + ## [0.6.0] - 2026-08-13 ### Added diff --git a/src/cache/parsed.ts b/src/cache/parsed.ts new file mode 100644 index 0000000..177cb2a --- /dev/null +++ b/src/cache/parsed.ts @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Project } from "ts-morph"; +import type { RippleConfig } from "../types/config.js"; +import type { ParsedFile } from "../types/parser.js"; +import { parseMany } from "../parser/parse.js"; +import { toPosix } from "../utils/paths.js"; + +/** + * Incremental parse cache. + * + * Parsing a file with ts-morph (imports, exports, symbols) is the dominant + * cost of an analysis run. For every discovered file we instead compute a + * cheap content hash; when the hash matches the cached one, the previously + * parsed surface is reused and ts-morph never touches the file. + * + * The cache is keyed by the discovery-affecting config (include/ignore), so + * changing discovery invalidates it wholesale. The parsed surface is only + * a function of file content — resolution, cycles and risk are recomputed + * fresh on every run, so cached runs are byte-identical to cold runs. + * + * The cache lives in `/.ripple/cache/` and is written atomically. + */ + +export const CACHE_SCHEMA = 1; +export const CACHE_FILE = path.join(".ripple", "cache", "parsed-v1.json"); + +/** + * Set `RIPPLE_NO_CACHE=1` to force a cold run (e.g. for benchmarks). The + * on-disk cache is left untouched. + */ +export const cacheEnabled = (): boolean => process.env.RIPPLE_NO_CACHE !== "1"; + +/** One cached file: its content hash plus the parsed surface. */ +export interface ParsedCacheEntry { + hash: string; + parsed: ParsedFile; +} + +interface ParsedCacheManifest { + schema: number; + configHash: string; + files: Record; +} + +/** sha256 hex of a file's bytes. */ +export async function contentHash(filePath: string): Promise { + const bytes = await fs.readFile(filePath); + return createHash("sha256").update(bytes).digest("hex"); +} + +/** + * Hash of the discovery-affecting config (include/ignore, sorted for + * stability). Aliases and tsconfig paths do not affect parsing, so they are + * deliberately excluded: resolution re-runs against cached surfaces. + */ +export function configCacheHash(config: RippleConfig): string { + const stable = { + include: [...config.include].sort(), + ignore: [...config.ignore].sort(), + }; + return createHash("sha256").update(JSON.stringify(stable)).digest("hex"); +} + +/** Relative POSIX path of a file under the project root. */ +function relPosix(rootDir: string, absPath: string): string { + return toPosix(path.relative(rootDir, absPath)); +} + +function cachePath(rootDir: string): string { + return path.join(rootDir, CACHE_FILE); +} + +/** Load the cache manifest; any problem (missing, corrupt, stale) → empty. */ +export async function loadParsedCache( + rootDir: string, + configHash: string, +): Promise> { + let raw: string; + try { + raw = await fs.readFile(cachePath(rootDir), "utf8"); + } catch { + return new Map(); + } + try { + const manifest = JSON.parse(raw) as Partial; + if (manifest.schema !== CACHE_SCHEMA || manifest.configHash !== configHash) { + return new Map(); + } + return new Map(Object.entries(manifest.files ?? {})); + } catch { + return new Map(); + } +} + +/** Persist the cache atomically (tmp file + rename). Never throws. */ +export async function saveParsedCache( + rootDir: string, + configHash: string, + entries: Map, +): Promise { + const manifest: ParsedCacheManifest = { + schema: CACHE_SCHEMA, + configHash, + files: Object.fromEntries(entries), + }; + const target = cachePath(rootDir); + const tmp = target + ".tmp"; + try { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(tmp, JSON.stringify(manifest), "utf8"); + await fs.rename(tmp, target); + } catch { + try { + await fs.rm(tmp, { force: true }); + } catch { + /* best effort cleanup */ + } + } +} + +export interface ParsedCacheStats { + /** Files served from the cache without re-parsing. */ + hits: number; + /** Files that had to be re-parsed. */ + misses: number; +} + +/** + * Load parsed surfaces for every discovered file, parsing only the files + * whose content changed since the last run. The result is ordered exactly + * like `filePaths`, so downstream output is identical to a cold run. + */ +export async function loadParsedFiles(options: { + project: Project; + rootDir: string; + filePaths: string[]; + config: RippleConfig; +}): Promise<{ parsedFiles: ParsedFile[]; stats: ParsedCacheStats }> { + const { project, rootDir, filePaths, config } = options; + if (!cacheEnabled()) { + return { + parsedFiles: parseMany(project, filePaths), + stats: { hits: 0, misses: filePaths.length }, + }; + } + const configHash = configCacheHash(config); + const cached = await loadParsedCache(rootDir, configHash); + + const hits = new Map(); + const stale: string[] = []; + const parsed = new Map(); + + for (const abs of filePaths) { + const rel = relPosix(rootDir, abs); + let hash: string; + try { + hash = await contentHash(abs); + } catch { + hash = ""; + } + const entry = cached.get(rel); + if (entry !== undefined && entry.hash === hash) { + hits.set(rel, entry); + parsed.set(rel, { ...entry.parsed, path: abs }); + } else { + stale.push(abs); + } + } + + if (stale.length > 0) { + for (const filePath of stale) { + project.getSourceFile(filePath)?.forget(); + } + for (const parsedFile of parseMany(project, stale)) { + parsed.set(relPosix(rootDir, parsedFile.path), parsedFile); + } + const next = new Map(hits); + for (const parsedFile of parsed.values()) { + const rel = relPosix(rootDir, parsedFile.path); + const hash = hits.get(rel)?.hash ?? (await contentHash(parsedFile.path).catch(() => "")); + next.set(rel, { hash, parsed: { ...parsedFile, path: rel } }); + } + await saveParsedCache(rootDir, configHash, next); + } + + const parsedFiles = filePaths.map((abs) => { + const rel = relPosix(rootDir, abs); + const parsedFile = parsed.get(rel); + if (parsedFile === undefined) { + return { + path: abs, + kind: "ts" as const, + imports: [], + exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] }, + symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] }, + parseError: "file disappeared during discovery", + }; + } + return parsedFile; + }); + + return { + parsedFiles, + stats: { hits: hits.size, misses: stale.length }, + }; +} diff --git a/src/commands/pipeline.ts b/src/commands/pipeline.ts index f8743c8..8c86b3d 100644 --- a/src/commands/pipeline.ts +++ b/src/commands/pipeline.ts @@ -1,7 +1,8 @@ import path from "node:path"; import { createTsProject } from "../parser/ts-project.js"; import { discoverSourceFiles } from "../scanner/discover.js"; -import { buildGraph } from "../graph/build.js"; +import { buildGraphFromParsed } from "../graph/build.js"; +import { loadParsedFiles } from "../cache/parsed.js"; import type { DependencyGraph } from "../types/graph.js"; import type { ProjectContext } from "../types/project.js"; import { detectEntryPoints } from "../analyzer/categorize.js"; @@ -10,12 +11,17 @@ import { fileNotFound } from "../utils/errors.js"; /** * Shared pipeline for commands that need the full dependency graph - * (analyze, graph, doctor). Loads context, discovers files, parses and + * (analyze, graph, diff, doctor). Loads context, discovers files, parses and * resolves everything into one graph, and detects entry points. + * + * Parsing is incremental: unchanged files are served from the on-disk cache + * (`.ripple/cache/`), so repeated runs only re-parse what actually changed. + * Resolution, cycles and stats are always recomputed from the parsed + * surfaces, keeping cached output byte-identical to cold runs. */ export interface PipelineResult { - graph: ReturnType; + graph: ReturnType; filePaths: string[]; entryPoints: Set; durationMs: number; @@ -29,7 +35,13 @@ export async function runPipeline(context: ProjectContext): Promise pathKey(p, context.rootDir))), diff --git a/src/graph/build.ts b/src/graph/build.ts index 454d19f..014dab5 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -21,8 +21,19 @@ export function buildGraph( filePaths: string[], context: ResolverContext, ): DependencyGraph { - const parsedFiles = parseMany(project, filePaths); + return buildGraphFromParsed(parseMany(project, filePaths), context); +} +/** + * Build the graph from already-parsed surfaces (used by the incremental + * parse cache, which reuses cached surfaces for unchanged files). The result + * is identical to `buildGraph`: resolution, cycles and stats are always + * recomputed from the parsed imports. + */ +export function buildGraphFromParsed( + parsedFiles: ParsedFile[], + context: ResolverContext, +): DependencyGraph { const nodes: DependencyGraph["nodes"] = new Map(); const forward: DependencyGraph["forward"] = new Map(); const reverse: DependencyGraph["reverse"] = new Map(); diff --git a/src/parser/parse.ts b/src/parser/parse.ts index 12b2dfb..e57df63 100644 --- a/src/parser/parse.ts +++ b/src/parser/parse.ts @@ -25,13 +25,24 @@ export function parseSourceFile(project: Project, filePath: string): ParsedFile }; } - return { - path: filePath, - kind: sourceFileKind(filePath) ?? "ts", - imports: extractImports(sourceFile), - exports: extractExports(sourceFile), - symbols: extractSymbols(sourceFile), - }; + try { + return { + path: filePath, + kind: sourceFileKind(filePath) ?? "ts", + imports: extractImports(sourceFile), + exports: extractExports(sourceFile), + symbols: extractSymbols(sourceFile), + }; + } catch (error) { + return { + path: filePath, + kind: sourceFileKind(filePath) ?? "ts", + imports: [], + exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] }, + symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] }, + parseError: error instanceof Error ? error.message : String(error), + }; + } } /** Parse many files, reusing the shared project. Never throws. */ diff --git a/src/scanner/discover.ts b/src/scanner/discover.ts index 48463bd..4738fce 100644 --- a/src/scanner/discover.ts +++ b/src/scanner/discover.ts @@ -20,7 +20,7 @@ export async function discoverSourceFiles(options: DiscoveryOptions): Promise glob !== "") - .concat(["**/node_modules/**"]); + .concat(["**/node_modules/**", "**/.ripple/**"]); const files = await globFiles({ cwd: options.rootDir, diff --git a/tests/unit/cache/parsed.test.ts b/tests/unit/cache/parsed.test.ts new file mode 100644 index 0000000..03abb31 --- /dev/null +++ b/tests/unit/cache/parsed.test.ts @@ -0,0 +1,277 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTsProject } from "../../../src/parser/ts-project.js"; +import { + CACHE_FILE, + configCacheHash, + contentHash, + loadParsedCache, + loadParsedFiles, + saveParsedCache, +} from "../../../src/cache/parsed.js"; +import type { RippleConfig } from "../../../src/types/config.js"; +import { basicFixture } from "../../helpers/fixtures.js"; + +const project = createTsProject(); + +function tempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function minimalConfig(overrides: Partial = {}): RippleConfig { + return { + include: ["**/*.{ts,tsx,js,jsx}"], + ignore: ["node_modules", "dist"], + aliases: {}, + tsconfigPath: "tsconfig.json", + risk: { + weights: { + affectedFiles: 0.3, + entryPoint: 0.15, + sharedUtility: 0.15, + publicExports: 0.1, + tests: 0.1, + routes: 0.1, + cycleMembership: 0.1, + }, + thresholds: { medium: 30, high: 55, critical: 80 }, + }, + diff: { gate: "high", allow: [] }, + ...overrides, + }; +} + +/** Copy the fixture's src tree into a temp project root. */ +function fixtureRoot(prefix: string): string { + const root = tempDir(prefix); + fs.cpSync(path.join(basicFixture, "src"), path.join(root, "src"), { recursive: true }); + return root; +} + +function fixturePaths(root: string): string[] { + return fs + .readdirSync(path.join(root, "src"), { recursive: true }) + .filter((name) => typeof name === "string" && /\.(ts|tsx|js|jsx)$/.test(name)) + .map((name) => path.join(root, "src", name as string)) + .sort((a, b) => a.localeCompare(b)); +} + +describe("cache location and hashing", () => { + it("stores the cache under .ripple/cache in the project root", () => { + expect(CACHE_FILE).toBe(path.join(".ripple", "cache", "parsed-v1.json")); + }); + + it("hashes file content deterministically", async () => { + const a = await contentHash(path.join(basicFixture, "src", "main.ts")); + const b = await contentHash(path.join(basicFixture, "src", "main.ts")); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + }); + + it("config hash is stable across key order and ignores non-discovery fields", () => { + const base = minimalConfig(); + const shuffled = minimalConfig({ + include: ["**/*.{ts,tsx,js,jsx}"], + ignore: ["dist", "node_modules"], + risk: { ...base.risk, thresholds: { medium: 1, high: 2, critical: 3 } }, + diff: { gate: "critical", allow: ["x/**"] }, + }); + expect(configCacheHash(base)).toBe(configCacheHash(shuffled)); + }); + + it("config hash changes when discovery changes", () => { + const base = minimalConfig(); + const changed = minimalConfig({ include: ["src/**/*.ts"] }); + expect(configCacheHash(base)).not.toBe(configCacheHash(changed)); + }); +}); + +describe("loadParsedCache / saveParsedCache", () => { + it("returns an empty map when no cache exists", async () => { + const root = tempDir("ripple-cache-none-"); + expect(await loadParsedCache(root, "abc")).toEqual(new Map()); + }); + + it("round-trips entries", async () => { + const root = fixtureRoot("ripple-cache-roundtrip-"); + const entries = new Map([ + [ + "src/main.ts", + { + hash: "h1", + parsed: { + path: "src/main.ts", + kind: "ts" as const, + imports: [], + exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] }, + symbols: { + functions: ["main"], + classes: [], + interfaces: [], + enums: [], + typeAliases: [], + }, + }, + }, + ], + ]); + await saveParsedCache(root, "cfg", entries); + const loaded = await loadParsedCache(root, "cfg"); + expect(loaded.size).toBe(1); + expect(loaded.get("src/main.ts")?.hash).toBe("h1"); + expect(loaded.get("src/main.ts")?.parsed.symbols.functions).toEqual(["main"]); + }); + + it("ignores a cache written for a different config hash", async () => { + const root = fixtureRoot("ripple-cache-config-"); + await saveParsedCache( + root, + "old-cfg", + new Map([ + [ + "src/main.ts", + { + hash: "h", + parsed: { + path: "src/main.ts", + kind: "ts" as const, + imports: [], + exports: { named: [], hasDefault: false, reExportedFrom: [], reExportedAll: [] }, + symbols: { functions: [], classes: [], interfaces: [], enums: [], typeAliases: [] }, + }, + }, + ], + ]), + ); + expect(await loadParsedCache(root, "new-cfg")).toEqual(new Map()); + }); + + it("ignores a corrupt cache file", async () => { + const root = tempDir("ripple-cache-corrupt-"); + fs.mkdirSync(path.join(root, ".ripple", "cache"), { recursive: true }); + fs.writeFileSync(path.join(root, CACHE_FILE), "{not json", "utf8"); + expect(await loadParsedCache(root, "cfg")).toEqual(new Map()); + }); + + it("ignores a cache with an unknown schema version", async () => { + const root = tempDir("ripple-cache-schema-"); + fs.mkdirSync(path.join(root, ".ripple", "cache"), { recursive: true }); + fs.writeFileSync( + path.join(root, CACHE_FILE), + JSON.stringify({ schema: 999, configHash: "cfg", files: {} }), + "utf8", + ); + expect(await loadParsedCache(root, "cfg")).toEqual(new Map()); + }); +}); + +describe("loadParsedFiles", () => { + it("parses everything on a cold run and serves hits on the next", async () => { + const root = fixtureRoot("ripple-cache-cold-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + + const cold = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(cold.stats.hits).toBe(0); + expect(cold.stats.misses).toBe(filePaths.length); + expect(cold.parsedFiles).toHaveLength(filePaths.length); + expect(cold.parsedFiles[0]!.path).toBe(filePaths[0]); + expect( + cold.parsedFiles.every( + (p) => + (Array.isArray(p.imports) && p.parseError === undefined) || p.parseError !== undefined, + ), + ).toBe(true); + expect(cold.parsedFiles.some((p) => p.imports.length > 0)).toBe(true); + + const warm = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(warm.stats.hits).toBe(filePaths.length); + expect(warm.stats.misses).toBe(0); + expect(warm.parsedFiles).toEqual(cold.parsedFiles); + }); + + it("re-parses only the file whose content changed", async () => { + const root = fixtureRoot("ripple-cache-stale-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + + const changed = path.join(root, "src", "main.ts"); + fs.writeFileSync( + changed, + 'import { Button } from "./components/Button";\n\nexport default Button;\n', + "utf8", + ); + const mainIdx = filePaths.findIndex((p) => p.endsWith("main.ts")); + const warm = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(warm.stats.hits).toBe(filePaths.length - 1); + expect(warm.stats.misses).toBe(1); + expect(warm.parsedFiles[mainIdx]!.exports.hasDefault).toBe(true); + expect(warm.parsedFiles[mainIdx]!.imports.map((i) => i.raw)).toContain("./components/Button"); + }); + + it("caches parse errors and keeps surfaces stable", async () => { + const root = fixtureRoot("ripple-cache-error-"); + fs.writeFileSync( + path.join(root, "src", "broken.ts"), + "import { from broken syntax ((\n", + "utf8", + ); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + + const cold = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + const broken = cold.parsedFiles.find((p) => p.path.endsWith("broken.ts")); + expect(broken?.parseError).toBeTruthy(); + + const warm = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + const brokenWarm = warm.parsedFiles.find((p) => p.path.endsWith("broken.ts")); + expect(brokenWarm?.parseError).toBe(broken?.parseError); + }); + + it("keeps output order aligned with filePaths after a mixed run", async () => { + const root = fixtureRoot("ripple-cache-order-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + fs.writeFileSync(path.join(root, "src", "new.ts"), "export const x = 1;\n", "utf8"); + const reordered = [...filePaths].reverse(); + const warm = await loadParsedFiles({ project, rootDir: root, filePaths: reordered, config }); + expect(warm.parsedFiles.map((p) => p.path)).toEqual(reordered); + }); + + it("is invalidated by a config change", async () => { + const root = fixtureRoot("ripple-cache-invalidate-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + const warm = await loadParsedFiles({ + project, + rootDir: root, + filePaths, + config: minimalConfig({ include: ["src/**/*.ts"] }), + }); + expect(warm.stats.hits).toBe(0); + expect(warm.stats.misses).toBe(filePaths.length); + }); + + it("respects RIPPLE_NO_CACHE to force a cold run", async () => { + const root = fixtureRoot("ripple-cache-nocache-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + + const previous = process.env.RIPPLE_NO_CACHE; + process.env.RIPPLE_NO_CACHE = "1"; + try { + const forced = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(forced.stats.hits).toBe(0); + expect(forced.stats.misses).toBe(filePaths.length); + } finally { + if (previous === undefined) delete process.env.RIPPLE_NO_CACHE; + else process.env.RIPPLE_NO_CACHE = previous; + } + }); +}); From b511c512060f2bf7fc1cea8ed294e5c50cba7b52 Mon Sep 17 00:00:00 2001 From: Ali Sher Date: Tue, 18 Aug 2026 12:04:08 +0500 Subject: [PATCH 2/4] feat(sarif): SARIF 2.1.0 output for diff and analyze - ripple diff --format sarif: one finding per changed file, error for CRITICAL/HIGH, warning for MEDIUM, note for LOW; allowlisted files are emitted as note with an in-source suppression - ripple analyze --sarif: single-file finding - stable primaryLocationLineHash fingerprints for GitHub Code Scanning deduplication; gate verdict still carried by the exit code - docs: README diff/analyze flag tables + Code Scanning upload example, CHANGELOG [Unreleased] - tests: unit (level mapping, fingerprints, suppressions, stability) and integration (analyze --sarif, diff --format sarif, updated format error) --- CHANGELOG.md | 6 + README.md | 24 ++- src/cli/program.ts | 12 +- src/commands/analyze.ts | 8 + src/commands/diff.ts | 18 +++ src/formatter/sarif.ts | 231 +++++++++++++++++++++++++++++ src/types/cli.ts | 3 +- tests/integration/cli.test.ts | 57 ++++++- tests/unit/formatter/sarif.test.ts | 136 +++++++++++++++++ 9 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 src/formatter/sarif.ts create mode 100644 tests/unit/formatter/sarif.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 59df241..d88def1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Parsing is now fully error-tolerant: a broken file that trips an extractor records a `parseError` instead of aborting the run, matching the documented "lower confidence instead of failing" behavior. +- `ripple diff --format sarif` and `ripple analyze --sarif` — SARIF 2.1.0 + output for GitHub Code Scanning. Every changed file becomes a finding + (`error` for CRITICAL/HIGH, `warning` for MEDIUM, `note` for LOW) with a + stable `primaryLocationLineHash` fingerprint for cross-run deduplication; + allowlisted files are emitted as `note` with an in-source suppression. The + gate verdict still rides on the exit code. ## [0.6.0] - 2026-08-13 diff --git a/README.md b/README.md index cdd4fa7..1ef4cd7 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ ripple [options] | Command | Flag | Description | | --------- | --------------------- | ---------------------------------------------------------------------------------------------- | | `analyze` | `-j, --json` | Emit the JSON report instead of the terminal report | +| `analyze` | `--sarif` | Emit a SARIF 2.1.0 report for code scanning | | `analyze` | `-v, --verbose` | Include the risk-factor point breakdown | | `analyze` | `-d, --depth ` | Cap the reverse traversal at `n` levels | | `analyze` | `-c, --config ` | Use a specific config file | @@ -235,7 +236,7 @@ ripple [options] | `diff` | `-j, --json` | Emit the JSON report instead of the terminal report | | `diff` | `-b, --base ` | Git ref to diff against (default: `origin/main`, `main`, `HEAD~1`, or `diff.base` from config) | | `diff` | `-g, --gate ` | Blocking level: `medium`, `high`, or `critical` (default: `high`, or `diff.gate` from config) | -| `diff` | `-f, --format ` | Output: `terminal`, `json`, or `github` (workflow annotations) | +| `diff` | `-f, --format ` | Output: `terminal`, `json`, `github` (workflow annotations), or `sarif` | | `diff` | `-d, --depth ` | Cap reverse traversal per file | | `diff` | `-c, --config ` | Use a specific config file | | `diff` | `--no-color` | Disable ANSI colors | @@ -333,6 +334,27 @@ verdict, so the job fails when the gate blocks: (`--json` is shorthand for `--format json`; the format flag also accepts `terminal`, the default.) +`--format sarif` emits SARIF 2.1.0, the format GitHub Code Scanning speaks: + +```bash +ripple diff --format sarif > ripple.sarif +``` + +Each analyzed change becomes a finding on the file — `error` when it is +CRITICAL/HIGH, `warning` for MEDIUM, `note` for LOW — with a stable +`primaryLocationLineHash` fingerprint so results deduplicate across runs. +Allowlisted files are emitted as `note` with an in-source suppression, so +they show as exempted rather than failing. Upload the report straight into +Code Scanning alerts: + +```yaml +- uses: actions/upload-sarif@v3 + with: + sarif_file: ripple.sarif +``` + +The exit code still carries the gate verdict regardless of format. + ## Configuration Ripple discovers `ripple.config.ts`, `.js`, `.cjs`, `.mjs`, or `.json` in the diff --git a/src/cli/program.ts b/src/cli/program.ts index 12cbca8..1d68ddd 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -41,6 +41,7 @@ export function createProgram(ctx: CommandContext): Command { ]), ) .option("-j, --json", "emit a machine-readable JSON report") + .option("--sarif", "emit a SARIF 2.1.0 report for code scanning") .option("-v, --verbose", "include the risk factor breakdown") .option("-d, --depth ", "cap the reverse traversal depth", parsePositiveInt) .option("-c, --config ", "path to a ripple config file") @@ -50,6 +51,7 @@ export function createProgram(ctx: CommandContext): Command { file, { json: Boolean(options.json), + sarif: Boolean(options.sarif), verbose: Boolean(options.verbose), ...(options.depth !== undefined ? { depth: options.depth as number } : {}), ...(options.config !== undefined ? { config: options.config as string } : {}), @@ -111,7 +113,11 @@ export function createProgram(ctx: CommandContext): Command { helpExamples(["ripple diff --base main --gate critical", "ripple diff --format github"]), ) .option("-j, --json", "emit a machine-readable JSON report") - .option("-f, --format ", "output format: terminal | json | github", parseDiffFormat) + .option( + "-f, --format ", + "output format: terminal | json | github | sarif", + parseDiffFormat, + ) .option("-v, --verbose", "include extra sections") .option("-b, --base ", "git ref to diff against (default: origin/main, main, HEAD~1)") .option( @@ -239,13 +245,13 @@ function parseGateLevel(value: string): "medium" | "high" | "critical" { /** Validate the `--format` argument. */ function parseDiffFormat(value: string): DiffFormat { - if (value === "terminal" || value === "json" || value === "github") { + if (value === "terminal" || value === "json" || value === "github" || value === "sarif") { return value; } throw new CommanderError( 1, "commander.invalidArgument", - `expected one of terminal | json | github, got "${value}"`, + `expected one of terminal | json | github | sarif, got "${value}"`, ); } diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index ded7109..6bb403b 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -4,6 +4,7 @@ import { createStageTracker } from "../ui/progress.js"; import { resolveColor } from "../ui/color.js"; import { buildAnalyzeJsonReport, renderAnalyzeReport } from "../output/report.js"; import { serializeJson } from "../formatter/json.js"; +import { buildAnalyzeSarif } from "../formatter/sarif.js"; import { requireNode, resolveTargetFile, runPipeline } from "./pipeline.js"; import { RippleError } from "../utils/errors.js"; import { pathExists } from "../utils/fs.js"; @@ -54,6 +55,13 @@ export async function analyzeCommand( return ExitCode.Success; } + if (options.sarif) { + ctx.writer.write( + serializeJson(buildAnalyzeSarif({ result, cwd: ctx.cwd, version: ctx.version })), + ); + return ExitCode.Success; + } + renderAnalyzeReport( result, { diff --git a/src/commands/diff.ts b/src/commands/diff.ts index eb3b11f..cc69b61 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -18,6 +18,7 @@ import { type TextStyle, } from "../formatter/text.js"; import { serializeJson } from "../formatter/json.js"; +import { buildDiffSarif } from "../formatter/sarif.js"; import { buildFileAnnotations, buildGateAnnotation, @@ -163,6 +164,23 @@ export async function diffCommand(options: DiffOptions, ctx: CommandContext): Pr return blocked ? ExitCode.Failure : ExitCode.Success; } + if (format === "sarif") { + ctx.writer.write( + serializeJson( + buildDiffSarif({ + baseLabel: changed.baseLabel, + entries, + gate, + counts, + blocked, + durationMs, + version: ctx.version, + }), + ), + ); + return blocked ? ExitCode.Failure : ExitCode.Success; + } + if (format === "github") { const annotations = [ ...buildFileAnnotations( diff --git a/src/formatter/sarif.ts b/src/formatter/sarif.ts new file mode 100644 index 0000000..4b34a66 --- /dev/null +++ b/src/formatter/sarif.ts @@ -0,0 +1,231 @@ +import { createHash } from "node:crypto"; +import type { AnalysisResult } from "../types/analysis.js"; +import type { RiskLevel } from "../types/risk.js"; +import type { DiffCounts } from "../commands/diff.js"; +import type { GateLevel } from "../types/output.js"; + +/** + * SARIF 2.1.0 output for `ripple diff --format sarif` and + * `ripple analyze --sarif`. + * + * The payload is shaped for GitHub Code Scanning uploads (`gh codeql + * upload-sarif` / `actions/upload-sarif`): every result carries a stable + * `partialFingerprints.primaryLocationLineHash` so findings deduplicate + * across runs, and allowlisted files are emitted at `note` with an in-source + * suppression so they show as exempted rather than as errors. + */ + +export type SarifLevel = "error" | "warning" | "note"; + +const LEVEL_INDEX: Record = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; + +const GATE_INDEX: Record = { medium: 1, high: 2, critical: 3 }; + +const INFORMATION_URI = "https://github.com/alimaandev/ripple"; + +/** Minimal structural typing for the SARIF 2.1.0 document we emit. */ +export interface SarifDocument { + $schema: string; + version: string; + runs: Array<{ + tool: { driver: Record }; + results: Array>; + properties?: Record; + }>; +} + +/** Map a Ripple risk level to a SARIF severity. */ +export function riskToSarifLevel(level: RiskLevel): SarifLevel { + switch (level) { + case "CRITICAL": + case "HIGH": + return "error"; + case "MEDIUM": + return "warning"; + default: + return "note"; + } +} + +/** Stable per-file fingerprint so GitHub deduplicates findings across runs. */ +export function primaryLocationHash(ruleId: string, uri: string, score: number): string { + return createHash("sha256") + .update(`${ruleId}|${uri}|${score.toFixed(1)}`) + .digest("hex"); +} + +/** The single rule Ripple reports against. */ +const RISK_RULE = { + id: "ripple/risk", + name: "riskScore", + shortDescription: { + text: "Risk of changing a file's blast radius", + }, + fullDescription: { + text: "Ripple scores how risky a change to a file is — its impact area, affected files, and circular-dependency membership — and gates merges on the resulting level.", + }, + defaultConfiguration: { level: "warning" }, + properties: { tags: ["ripple", "impact-analysis"] }, +}; + +interface DiffSarifEntry { + rel: string; + result: AnalysisResult; + allowed: boolean; +} + +/** File-level SARIF result for one analyzed file. */ +function fileResult(entry: DiffSarifEntry, gate: GateLevel): Record { + const { rel, result, allowed } = entry; + const level = allowed ? "note" : riskToSarifLevel(result.risk.level); + const blocked = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate]; + const score = result.risk.score.toFixed(1); + const affected = result.summary.affectedFiles; + const message = allowed + ? `Allowlisted: ${result.risk.level} risk (${score}/100) - ${affected} affected file(s)` + : `${result.risk.level} risk (${score}/100) - ${affected} affected file(s)`; + + const entryResult: Record = { + ruleId: "ripple/risk", + level, + message: { text: message }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: rel }, + region: { startLine: 1 }, + }, + }, + ], + partialFingerprints: { + primaryLocationLineHash: primaryLocationHash("ripple/risk", rel, result.risk.score), + }, + properties: { + ripple: { + score: result.risk.score, + level: result.risk.level, + affectedFiles: result.summary.affectedFiles, + targetInCycle: result.targetInCycle, + allowed, + blocked, + gate, + }, + }, + }; + + if (allowed) { + entryResult.suppressions = [ + { kind: "inSource", justification: "Allowlisted via diff.allow in ripple config" }, + ]; + } + + return entryResult; +} + +/** + * Build the SARIF document for `ripple diff`. Analyzed files become results; + * changed-but-skipped (non-source) files are omitted. The gate counts ride + * along in `run.properties` so the Code Scanning result and the CI verdict + * stay consistent. + */ +export function buildDiffSarif(options: { + baseLabel: string; + entries: DiffSarifEntry[]; + gate: GateLevel; + counts: DiffCounts; + blocked: boolean; + durationMs: number; + version: string; +}): SarifDocument { + const { baseLabel, entries, gate, counts, blocked, durationMs, version } = options; + const results = entries.map((entry) => fileResult(entry, gate)); + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "ripple", + fullName: "Ripple — dependency impact analysis", + informationUri: INFORMATION_URI, + version, + rules: [RISK_RULE], + }, + }, + results, + properties: { + ripple: { + command: "diff", + base: baseLabel, + gate, + blocked, + counts, + durationMs, + }, + }, + }, + ], + }; +} + +/** Build the SARIF document for `ripple analyze` (single-file result). */ +export function buildAnalyzeSarif(options: { + result: AnalysisResult; + cwd: string; + version: string; +}): SarifDocument { + const { result, cwd, version } = options; + const rel = result.targetPath + .slice(cwd.length) + .replace(/^[/\\]+/, "") + .replace(/\\/g, "/"); + const uri = rel || result.targetPath; + + return { + $schema: "https://json.schemastore.org/sarif-2.1.0.json", + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "ripple", + fullName: "Ripple — dependency impact analysis", + informationUri: INFORMATION_URI, + version, + rules: [RISK_RULE], + }, + }, + results: [ + { + ruleId: "ripple/risk", + level: riskToSarifLevel(result.risk.level), + message: { + text: `${result.risk.level} risk (${result.risk.score.toFixed(1)}/100) - ${result.summary.affectedFiles} affected file(s)`, + }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri }, + region: { startLine: 1 }, + }, + }, + ], + partialFingerprints: { + primaryLocationLineHash: primaryLocationHash("ripple/risk", uri, result.risk.score), + }, + properties: { + ripple: { + score: result.risk.score, + level: result.risk.level, + affectedFiles: result.summary.affectedFiles, + targetInCycle: result.targetInCycle, + }, + }, + }, + ], + }, + ], + }; +} diff --git a/src/types/cli.ts b/src/types/cli.ts index e5ee8c6..939913c 100644 --- a/src/types/cli.ts +++ b/src/types/cli.ts @@ -6,6 +6,7 @@ /** Parsed command-line options for `ripple analyze`. */ export interface AnalyzeOptions { json: boolean; + sarif: boolean; verbose: boolean; /** Whether to emit ANSI colors. `false` when `--no-color` is passed. */ color?: boolean; @@ -55,7 +56,7 @@ export interface InitOptions { } /** Supported output formats for `ripple diff`. */ -export type DiffFormat = "terminal" | "json" | "github"; +export type DiffFormat = "terminal" | "json" | "github" | "sarif"; /** Parsed command-line options for `ripple diff`. */ export interface DiffOptions { diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 5f7fbb4..640f950 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -70,6 +70,30 @@ describe("ripple analyze", () => { expect(report.targetInCycle).toBe(false); }); + it("emits a SARIF report for a fixture file", { timeout }, () => { + const result = runCli(["analyze", "src/authentication/login.ts", "--sarif"], basicFixture); + expect(result.code).toBe(0); + const doc = JSON.parse(result.stdout) as { + version: string; + runs: Array<{ + tool: { driver: { name: string; version: string } }; + results: Array<{ + ruleId: string; + level: string; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + }>; + }>; + }; + expect(doc.version).toBe("2.1.0"); + expect(doc.runs[0]!.tool.driver.name).toBe("ripple"); + const entry = doc.runs[0]!.results[0]!; + expect(entry.ruleId).toBe("ripple/risk"); + expect(entry.level).toBe("warning"); + expect(entry.locations[0]!.physicalLocation.artifactLocation.uri).toBe( + "src/authentication/login.ts", + ); + }); + it("renders a terminal report without --json", { timeout: 90_000 }, () => { const result = runCli(["analyze", "src/authentication/login.ts"], basicFixture); expect(result.code).toBe(0); @@ -478,12 +502,43 @@ describe("ripple diff", () => { expect(result.stdout).toContain("Gate passed"); }); + diffIt("emits SARIF 2.1.0 for code scanning", { timeout: 90_000 }, () => { + const dir = gitRepo(); + writeTree(dir); + fs.writeFileSync( + path.join(dir, "src", "b.ts"), + 'import { a } from "./a";\nexport const b = a + 1;\nexport const g = a * 2;\n', + ); + + const result = runCli(["diff", "--format", "sarif", "--gate", "critical"], dir); + expect(result.code).toBe(0); + const doc = JSON.parse(result.stdout) as { + version: string; + runs: Array<{ + tool: { driver: { name: string } }; + results: Array<{ + ruleId: string; + level: string; + locations: Array<{ physicalLocation: { artifactLocation: { uri: string } } }>; + partialFingerprints: Record; + }>; + }>; + }; + expect(doc.version).toBe("2.1.0"); + expect(doc.runs[0]!.tool.driver.name).toBe("ripple"); + const entry = doc.runs[0]!.results[0]!; + expect(entry.ruleId).toBe("ripple/risk"); + expect(entry.level).toBe("note"); + expect(entry.locations[0]!.physicalLocation.artifactLocation.uri).toBe("src/b.ts"); + expect(entry.partialFingerprints.primaryLocationLineHash).toMatch(/^[0-9a-f]{64}$/); + }); + diffIt("rejects unknown --format values", { timeout: 90_000 }, () => { const dir = gitRepo(); writeTree(dir); const result = runCli(["diff", "--format", "xml"], dir); expect(result.code).toBe(1); - expect(result.stderr).toContain("expected one of terminal | json | github"); + expect(result.stderr).toContain("expected one of terminal | json | github | sarif"); }); diffIt("renders a terminal report", { timeout: 90_000 }, () => { diff --git a/tests/unit/formatter/sarif.test.ts b/tests/unit/formatter/sarif.test.ts new file mode 100644 index 0000000..466506a --- /dev/null +++ b/tests/unit/formatter/sarif.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { + buildAnalyzeSarif, + buildDiffSarif, + riskToSarifLevel, +} from "../../../src/formatter/sarif.js"; +import type { AnalysisResult } from "../../../src/types/analysis.js"; +import type { DependencyGraph } from "../../../src/types/graph.js"; +import type { DiffCounts } from "../../../src/commands/diff.js"; +import { basicFixture } from "../../helpers/fixtures.js"; +import path from "node:path"; + +function fakeResult(overrides: Partial = {}): AnalysisResult { + return { + targetPath: path.join(basicFixture, "src", "auth", "token.ts"), + rootDir: basicFixture, + risk: { level: "HIGH", score: 72.5, factors: [] }, + summary: { + affectedFiles: 4, + maxDepth: 2, + confidence: 1, + routes: 2, + components: 1, + tests: 1, + utilities: 0, + entries: 0, + topImpact: [], + }, + affected: new Map(), + targetInCycle: false, + maxDepth: 2, + durationMs: 10, + graph: { + nodes: new Map(), + forward: new Map(), + reverse: new Map(), + external: new Map(), + cycles: [], + stats: { + files: 1, + parsed: 1, + internalEdges: 0, + unresolvedEdges: 0, + externalEdges: 0, + cycles: 0, + }, + } as DependencyGraph, + ...overrides, + }; +} + +const counts: DiffCounts = { low: 1, medium: 0, high: 1, critical: 0 }; + +describe("riskToSarifLevel", () => { + it("maps CRITICAL and HIGH to error, MEDIUM to warning, LOW to note", () => { + expect(riskToSarifLevel("CRITICAL")).toBe("error"); + expect(riskToSarifLevel("HIGH")).toBe("error"); + expect(riskToSarifLevel("MEDIUM")).toBe("warning"); + expect(riskToSarifLevel("LOW")).toBe("note"); + }); +}); + +describe("buildDiffSarif", () => { + const doc = buildDiffSarif({ + baseLabel: "origin/main", + entries: [ + { rel: "src/auth/token.ts", result: fakeResult(), allowed: false }, + { + rel: "src/legacy/session.ts", + result: fakeResult({ risk: { level: "LOW", score: 12.4, factors: [] } }), + allowed: true, + }, + ], + gate: "high", + counts, + blocked: true, + durationMs: 118, + version: "0.7.0", + }); + + it("emits SARIF 2.1.0 with the ripple driver", () => { + expect(doc.version).toBe("2.1.0"); + expect(doc.$schema).toContain("sarif-2.1.0"); + const driver = doc.runs[0]!.tool.driver as Record; + expect(driver.name).toBe("ripple"); + expect(driver.version).toBe("0.7.0"); + }); + + it("scores a blocking HIGH result as error with a fingerprint", () => { + const first = doc.runs[0]!.results[0]! as Record; + expect(first.ruleId).toBe("ripple/risk"); + expect(first.level).toBe("error"); + const location = (first.locations as Array>)![0]!; + const artifact = location.physicalLocation as Record; + expect((artifact.artifactLocation as Record).uri).toBe("src/auth/token.ts"); + const fp = first.partialFingerprints as Record; + expect(fp.primaryLocationLineHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("emits allowlisted files as note with an in-source suppression", () => { + const second = doc.runs[0]!.results[1]! as Record; + expect(second.level).toBe("note"); + expect(second.suppressions).toEqual([expect.objectContaining({ kind: "inSource" })]); + }); + + it("carries the gate verdict and counts in run properties", () => { + const props = doc.runs[0]!.properties as Record; + const ripple = props.ripple as Record; + expect(ripple.gate).toBe("high"); + expect(ripple.blocked).toBe(true); + }); +}); + +describe("buildAnalyzeSarif", () => { + const result = fakeResult(); + const doc = buildAnalyzeSarif({ result, cwd: basicFixture, version: "0.7.0" }); + + it("produces a single result for the target file", () => { + expect(doc.runs[0]!.results).toHaveLength(1); + const entry = doc.runs[0]!.results[0]! as Record; + expect(entry.level).toBe("error"); + const location = (entry.locations as Array>)![0]!; + const artifact = location.physicalLocation as Record; + expect((artifact.artifactLocation as Record).uri).toBe( + path.join("src", "auth", "token.ts").replace(/\\/g, "/"), + ); + }); + + it("fingerprints are stable for the same file and score", () => { + const a = buildAnalyzeSarif({ result, cwd: basicFixture, version: "0.7.0" }); + const b = buildAnalyzeSarif({ result, cwd: basicFixture, version: "0.7.0" }); + expect((a.runs[0]!.results[0]! as Record).partialFingerprints).toEqual( + (b.runs[0]!.results[0]! as Record).partialFingerprints, + ); + }); +}); From 685e8bb49445f6e49995f761738c39df39999794 Mon Sep 17 00:00:00 2001 From: Ali Sher Date: Tue, 18 Aug 2026 12:28:56 +0500 Subject: [PATCH 3/4] feat(mcp): Model Context Protocol server for AI agents ripple mcp serves Ripple's analysis over stdio as MCP tools: - impact: blast radius of a file (affected files, routes, tests, components, risk level) - dependents: who imports a file, up to a depth - risk: score with factor breakdown - gate_status: current change set vs the merge gate, pass/block verdict Hand-rolled JSON-RPC 2.0 core (initialize, ping, tools/list, tools/call) - no new dependencies. Tool failures return isError results instead of killing the session; stdout stays strict protocol-only. Project loads lazily on first tool call and is cached for the session. docs: README MCP section with client config example, CHANGELOG [Unreleased] tests: protocol unit tests (10), tool unit tests (10), and two spawn-based integration tests exercising the real stdio server --- CHANGELOG.md | 6 + README.md | 33 ++++ src/cli/program.ts | 21 +++ src/commands/mcp.ts | 60 +++++++ src/mcp/server.ts | 190 ++++++++++++++++++++ src/mcp/tools.ts | 328 ++++++++++++++++++++++++++++++++++ tests/integration/cli.test.ts | 99 +++++++++- tests/unit/mcp/server.test.ts | 142 +++++++++++++++ tests/unit/mcp/tools.test.ts | 168 +++++++++++++++++ 9 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 src/commands/mcp.ts create mode 100644 src/mcp/server.ts create mode 100644 src/mcp/tools.ts create mode 100644 tests/unit/mcp/server.test.ts create mode 100644 tests/unit/mcp/tools.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d88def1..5898f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). stable `primaryLocationLineHash` fingerprint for cross-run deduplication; allowlisted files are emitted as `note` with an in-source suppression. The gate verdict still rides on the exit code. +- `ripple mcp` — a Model Context Protocol server over stdio that exposes + Ripple's analysis as tools for AI coding agents: `impact` (blast radius of + a file), `dependents` (who imports a file, up to a depth), `risk` (score + with factor breakdown) and `gate_status` (does the current change set pass + the merge gate). Point any MCP client at `ripple mcp` to risk-check + refactors before they happen. ## [0.6.0] - 2026-08-13 diff --git a/README.md b/README.md index 1ef4cd7..8add3fd 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,11 @@ ripple [options] | `diff` | `-c, --config ` | Use a specific config file | | `diff` | `--no-color` | Disable ANSI colors | +### `ripple mcp` + +Serve Ripple's analysis tools over the Model Context Protocol (see +[the MCP section](#ripple-mcp-1) below). + ### `ripple doctor` Independent health checks — config validity, tsconfig presence, source @@ -355,6 +360,34 @@ Code Scanning alerts: The exit code still carries the gate verdict regardless of format. +### `ripple mcp` + +`ripple mcp` exposes Ripple's analysis to AI coding agents as a Model +Context Protocol server over stdio. Point any MCP client at the command and +the agent can check a file's blast radius before touching it: + +```json +{ + "mcpServers": { + "ripple": { "command": "npx", "args": ["@alimaandev/ripple", "mcp"] } + } +} +``` + +Four tools are served: + +| Tool | Inputs | Returns | +| -------------- | ------------------------- | ----------------------------------------------------------------------- | +| `impact` | `file`, `maxDepth?` | Blast radius: affected files, routes, tests, components, risk level | +| `dependents` | `file`, `depth?` | Who imports the file, up to a depth (default 1, direct) | +| `risk` | `file` | Risk score with the factor breakdown behind it | +| `gate_status` | `base?`, `gate?` | Current change set vs the merge gate: files, levels, pass/block verdict | + +A typical agent loop: `impact src/auth/session.ts` before refactoring, +`dependents` to see who breaks, then `gate_status` after editing to confirm +the gate still passes. Tool results are JSON text; failures return +`isError` results instead of crashing the session. + ## Configuration Ripple discovers `ripple.config.ts`, `.js`, `.cjs`, `.mjs`, or `.json` in the diff --git a/src/cli/program.ts b/src/cli/program.ts index 1d68ddd..2cf9485 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -4,6 +4,7 @@ import { graphCommand } from "../commands/graph.js"; import { doctorCommand } from "../commands/doctor.js"; import { initCommand } from "../commands/init.js"; import { diffCommand } from "../commands/diff.js"; +import { mcpCommand } from "../commands/mcp.js"; import { RippleError } from "../utils/errors.js"; import { ExitCode, type CommandContext, type DiffFormat, type GraphFormat } from "../types/cli.js"; @@ -195,6 +196,26 @@ export function createProgram(ctx: CommandContext): Command { } }); + program + .command("mcp") + .description("Serve Ripple analysis tools over the Model Context Protocol (stdio)") + .addHelpText( + "after", + helpExamples(['ripple mcp # point an MCP client (Claude, Cursor, ...) at "ripple mcp"']), + ) + .option("-c, --config ", "path to a ripple config file") + .action(async (options: Record) => { + const exitCode = await mcpCommand( + { + config: options.config as string | undefined, + }, + ctx, + ); + if (exitCode !== ExitCode.Success) { + throw new ExitError(exitCode); + } + }); + program .command("version") .description("Print the Ripple version") diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..85e305c --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,60 @@ +import { createInterface } from "node:readline"; +import { loadProjectContext } from "../config/loader.js"; +import { runPipeline } from "./pipeline.js"; +import { changedFiles } from "../git/changed.js"; +import { McpServer } from "../mcp/server.js"; +import { createMcpTools, type ProjectSnapshot } from "../mcp/tools.js"; +import { ExitCode } from "../types/cli.js"; +import type { CommandContext } from "../types/cli.js"; + +/** + * `ripple mcp` + * + * Exposes Ripple's analysis as Model Context Protocol tools over stdio, so + * AI coding agents can check a file's blast radius, list its dependents, + * score the risk of touching it, and verify the merge gate before + * refactoring. + * + * stdio is a strict contract here: the server speaks one JSON-RPC message + * per line on stdout and nothing else. All human-readable output goes to + * stderr. + */ + +export interface McpOptions { + config?: string; +} + +export async function mcpCommand(options: McpOptions, ctx: CommandContext): Promise { + let snapshot: ProjectSnapshot | null = null; + const loadProject = async (): Promise => { + if (!snapshot) { + const context = await loadProjectContext(ctx.cwd, options.config); + const { graph, entryPoints } = await runPipeline(context); + snapshot = { context, graph, entryPoints }; + } + return snapshot; + }; + + const server = new McpServer({ + name: "ripple", + version: ctx.version, + tools: createMcpTools({ + cwd: ctx.cwd, + loadProject, + getChanged: (base) => changedFiles(ctx.cwd, base), + }), + }); + + const stdin = createInterface({ input: process.stdin, crlfDelay: Infinity }); + await new Promise((resolve) => { + stdin.on("line", (line) => { + if (!line.trim()) return; + void server.handleLine(line).then((response) => { + if (response) process.stdout.write(response); + }); + }); + stdin.on("close", () => resolve()); + }); + + return ExitCode.Success; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..3c7b849 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,190 @@ +/** + * Minimal Model Context Protocol server core (JSON-RPC 2.0 over stdio, + * newline-delimited messages). + * + * Implements just what Ripple needs: `initialize`, `ping`, + * `tools/list`, `tools/call` and the `notifications/initialized` lifecycle + * notification. Everything else surfaces as a standard JSON-RPC error so + * clients degrade gracefully instead of hanging. + * + * The core is transport-agnostic: `handleLine` takes one inbound message and + * returns the response to write (or `null` for notifications), which makes it + * unit-testable without spawning processes. + */ + +export interface McpTool { + name: string; + description: string; + /** JSON Schema draft-07 object describing `arguments`. */ + inputSchema: Record; + handler: (args: Record) => Promise | McpToolResult; +} + +export interface McpToolResult { + /** Markdown or JSON text returned to the client. */ + text: string; + /** Marks the result as an error while keeping the server alive. */ + isError?: boolean; +} + +export interface McpServerOptions { + name: string; + version: string; + tools: McpTool[]; +} + +const PROTOCOL_VERSION = "2025-06-18"; + +const ERR_PARSE = -32700; +const ERR_INVALID_REQUEST = -32600; +const ERR_INTERNAL = -32603; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export class McpServer { + private readonly options: McpServerOptions; + private readonly tools = new Map(); + + constructor(options: McpServerOptions) { + this.options = options; + for (const tool of options.tools) { + this.tools.set(tool.name, tool); + } + } + + /** + * Process one inbound message. Returns the JSON-RPC response line to write + * back, or `null` for notifications and invalid requests. + */ + async handleLine(line: string): Promise { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + return this.response(undefined, { + code: ERR_PARSE, + message: "Parse error: not valid JSON", + }); + } + + if (!isObject(message) || message.jsonrpc !== "2.0") { + return this.response(undefined, { + code: ERR_INVALID_REQUEST, + message: "Invalid Request: expected a JSON-RPC 2.0 object", + }); + } + + const hasId = "id" in message; + if (message.method === "notifications/initialized") { + return null; + } + + if (!hasId) { + return null; + } + + const method = message.method; + if (typeof method !== "string") { + return this.response(message.id, { + code: ERR_INVALID_REQUEST, + message: "Invalid Request: method must be a string", + }); + } + + try { + const result = await this.dispatch(method, message.params); + return this.response(message.id, result); + } catch (error) { + return this.response(message.id, { + code: ERR_INTERNAL, + message: error instanceof Error ? error.message : "Internal error", + }); + } + } + + private async dispatch(method: string, params: unknown): Promise { + switch (method) { + case "initialize": + return { + protocolVersion: this.protocolVersion(params), + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: this.options.name, version: this.options.version }, + }; + case "ping": + return {}; + case "tools/list": + return { + tools: [...this.tools.values()].map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + case "tools/call": + return this.callTool(params); + case "tools/list_changed": + throw new Error("Tool list changes are not supported by this server"); + default: + throw new Error(`Method not found: ${method}`); + } + } + + private protocolVersion(params: unknown): string { + if (isObject(params) && typeof params.protocolVersion === "string") { + return params.protocolVersion; + } + return PROTOCOL_VERSION; + } + + private async callTool(params: unknown): Promise { + if (!isObject(params) || typeof params.name !== "string") { + throw new Error("Invalid params: expected { name: string, arguments?: object }"); + } + const tool = this.tools.get(params.name); + if (!tool) { + throw new Error(`Unknown tool: ${params.name}`); + } + + const args = + isObject(params.arguments) || params.arguments === undefined + ? (params.arguments ?? {}) + : null; + if (args === null) { + throw new Error(`Invalid arguments for tool ${params.name}: expected an object`); + } + + try { + const result = await tool.handler(args); + return { + content: [{ type: "text", text: result.text }], + ...(result.isError ? { isError: true } : {}), + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : "Tool failed", + }, + ], + isError: true, + }; + } + } + + private response(id: unknown, payload: { code: number; message: string } | unknown): string { + if ( + typeof payload === "object" && + payload !== null && + "code" in payload && + typeof payload.code === "number" + ) { + const errorPayload = payload as Record; + const error = { code: errorPayload.code, message: String(errorPayload.message) }; + return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error })}\n`; + } + return `${JSON.stringify({ jsonrpc: "2.0", id: id ?? null, result: payload })}\n`; + } +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..d3bd3f5 --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,328 @@ +import path from "node:path"; +import { analyzeFile } from "../analyzer/analyze.js"; +import type { ProjectContext } from "../types/project.js"; +import type { DependencyGraph } from "../types/graph.js"; +import type { AnalysisResult } from "../types/analysis.js"; +import { pathKey } from "../utils/paths.js"; +import type { ChangedFiles } from "../git/changed.js"; +import type { McpTool, McpToolResult } from "./server.js"; +import { RippleError } from "../utils/errors.js"; +import { ExitCode } from "../types/cli.js"; + +/** + * The `ripple mcp` tool set. Each tool is a thin, deterministic wrapper over + * the same analysis pipeline the CLI uses, returning JSON text so AI clients + * can reason over exact numbers. + */ + +export interface ProjectSnapshot { + context: ProjectContext; + graph: DependencyGraph; + entryPoints: Set; +} + +export interface McpToolDeps { + cwd: string; + loadProject: () => Promise; + getChanged: (base?: string) => ChangedFiles; +} + +const MAX_DEPENDENTS = 200; + +function ok(text: string): McpToolResult { + return { text }; +} + +function fail(error: unknown): McpToolResult { + return { text: error instanceof Error ? error.message : "Tool failed", isError: true }; +} + +/** Validate a required string argument. */ +function requireString(args: Record, key: string, tool: string): string { + const value = args[key]; + if (typeof value !== "string" || value.trim() === "") { + throw new RippleError(`Missing required argument "${key}" for ${tool}`, ExitCode.Failure); + } + return value; +} + +function optionalPositiveInt( + args: Record, + key: string, + tool: string, +): number | undefined { + const value = args[key]; + if (value === undefined) return undefined; + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new RippleError( + `Argument "${key}" for ${tool} must be a positive integer`, + ExitCode.Failure, + ); + } + return value; +} + +function resolveFile(cwd: string, file: string): string { + return path.isAbsolute(file) ? file : path.join(cwd, file); +} + +function analyzeIn( + project: ProjectSnapshot, + cwd: string, + file: string, + maxDepth?: number, +): AnalysisResult { + const { context, graph, entryPoints } = project; + const targetPath = resolveFile(cwd, file); + const key = pathKey(targetPath, context.rootDir); + const node = graph.nodes.get(key); + if (!node) { + throw new RippleError( + `File not found in the project graph: ${file}. Is it a source file covered by ripple's discovery?`, + ExitCode.NotFound, + ); + } + return analyzeFile({ + graph, + context, + entryPoints, + targetKey: key, + targetPath, + durationMs: 0, + ...(maxDepth !== undefined ? { maxDepth } : {}), + }); +} + +function relPath(targetPath: string, cwd: string): string { + const rel = targetPath.slice(cwd.length).replace(/^[/\\]+/, ""); + return (rel || targetPath).replace(/\\/g, "/"); +} + +const IMPACT_SCHEMA = { + type: "object", + properties: { + file: { + type: "string", + description: "Project-relative or absolute path of the file to analyze.", + }, + maxDepth: { + type: "integer", + minimum: 1, + description: "Cap the reverse traversal depth (default: unlimited).", + }, + }, + required: ["file"], +} satisfies Record; + +const DEPENDENTS_SCHEMA = { + type: "object", + properties: { + file: { + type: "string", + description: "Project-relative or absolute path of the file.", + }, + depth: { + type: "integer", + minimum: 1, + description: "Max dependency depth to report (default: 1, direct dependents only).", + }, + }, + required: ["file"], +} satisfies Record; + +const RISK_SCHEMA = { + type: "object", + properties: { + file: { + type: "string", + description: "Project-relative or absolute path of the file.", + }, + }, + required: ["file"], +} satisfies Record; + +const GATE_SCHEMA = { + type: "object", + properties: { + base: { + type: "string", + description: "Git ref to diff against (default: origin/main, main, or HEAD~1).", + }, + gate: { + type: "string", + enum: ["medium", "high", "critical"], + description: "Risk level that blocks the merge (default: high).", + }, + }, +} satisfies Record; + +const LEVEL_INDEX: Record = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 }; + +export function createMcpTools(deps: McpToolDeps): McpTool[] { + const { cwd, loadProject, getChanged } = deps; + + return [ + { + name: "impact", + description: + "Blast-radius analysis of a file: how many files, routes, tests and components would be affected by changing it, plus its risk score. Call before editing or refactoring a file so the change set stays low-risk.", + inputSchema: IMPACT_SCHEMA, + handler: async (args) => { + try { + const file = requireString(args, "file", "impact"); + const maxDepth = optionalPositiveInt(args, "maxDepth", "impact"); + const project = await loadProject(); + const result = analyzeIn(project, cwd, file, maxDepth); + return ok( + JSON.stringify( + { + file: relPath(result.targetPath, cwd), + risk: { score: result.risk.score, level: result.risk.level }, + summary: result.summary, + targetInCycle: result.targetInCycle, + }, + null, + 2, + ), + ); + } catch (error) { + return fail(error); + } + }, + }, + { + name: "dependents", + description: + "List the files that depend on a file (its importers), up to a depth. Depth 1 is direct dependents; deeper levels are transitive. Useful to know who breaks when the file changes.", + inputSchema: DEPENDENTS_SCHEMA, + handler: async (args) => { + try { + const file = requireString(args, "file", "dependents"); + const depth = optionalPositiveInt(args, "depth", "dependents") ?? 1; + const project = await loadProject(); + const result = analyzeIn(project, cwd, file); + const dependents = [...result.affected.values()] + .filter((affected) => affected.depth <= depth) + .sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path)) + .map((affected) => ({ + path: relPath(affected.path, cwd), + depth: affected.depth, + direct: affected.direct, + inCycle: affected.inCycle, + categories: affected.categories, + })); + return ok( + JSON.stringify( + { + file: relPath(result.targetPath, cwd), + depth, + count: dependents.length, + truncated: dependents.length > MAX_DEPENDENTS, + dependents: dependents.slice(0, MAX_DEPENDENTS), + }, + null, + 2, + ), + ); + } catch (error) { + return fail(error); + } + }, + }, + { + name: "risk", + description: + "The risk score (0-100) of changing a file and the factor breakdown behind it. Cheap way to compare the danger of different refactor targets.", + inputSchema: RISK_SCHEMA, + handler: async (args) => { + try { + const file = requireString(args, "file", "risk"); + const project = await loadProject(); + const result = analyzeIn(project, cwd, file); + return ok( + JSON.stringify( + { + file: relPath(result.targetPath, cwd), + score: result.risk.score, + level: result.risk.level, + targetInCycle: result.targetInCycle, + factors: result.risk.factors, + }, + null, + 2, + ), + ); + } catch (error) { + return fail(error); + } + }, + }, + { + name: "gate_status", + description: + "Check the current change set against the ripple merge gate: which files changed since the base ref, their risk levels, and whether the gate passes or blocks. Call after editing files to verify the changes are safe to merge.", + inputSchema: GATE_SCHEMA, + handler: async (args) => { + try { + const base = args.base; + const gate = args.gate; + if (base !== undefined && typeof base !== "string") { + return fail( + new RippleError('Argument "base" for gate_status must be a string', ExitCode.Failure), + ); + } + if (gate !== undefined && gate !== "medium" && gate !== "high" && gate !== "critical") { + return fail( + new RippleError( + 'Argument "gate" for gate_status must be medium | high | critical', + ExitCode.Failure, + ), + ); + } + const changed = getChanged(base); + const project = await loadProject(); + const resolvedGate = gate ?? project.context.config.diff.gate ?? "high"; + const gateIndex = LEVEL_INDEX[resolvedGate.toUpperCase()] ?? 3; + + const files: Array> = []; + const counts = { low: 0, medium: 0, high: 0, critical: 0 }; + let blocked = false; + for (const rel of changed.files) { + try { + const result = analyzeIn(project, cwd, rel); + const level = result.risk.level; + counts[level.toLowerCase() as keyof typeof counts] += 1; + if ((LEVEL_INDEX[level] ?? 1) >= gateIndex) blocked = true; + files.push({ + file: rel, + analyzed: true, + level, + score: result.risk.score, + affectedFiles: result.summary.affectedFiles, + targetInCycle: result.targetInCycle, + }); + } catch { + files.push({ file: rel, analyzed: false }); + } + } + + return ok( + JSON.stringify( + { + base: changed.baseLabel, + changedFiles: changed.files.length, + files, + counts, + gate: { level: resolvedGate, blocked, verdict: blocked ? "block" : "pass" }, + }, + null, + 2, + ), + ); + } catch (error) { + return fail(error); + } + }, + }, + ]; +} diff --git a/tests/integration/cli.test.ts b/tests/integration/cli.test.ts index 640f950..3d7a447 100644 --- a/tests/integration/cli.test.ts +++ b/tests/integration/cli.test.ts @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -666,3 +666,100 @@ describe("ripple diff allowlist", () => { expect(result.stdout).toContain("Gate passed"); }); }); + +describe("ripple mcp", () => { + const timeout = 90_000; + + it("serves the ripple tools over stdio and analyzes a file", { timeout }, async () => { + const child = spawn(process.execPath, [jitiCli, bin, "mcp"], { + cwd: basicFixture, + env: { ...process.env, JITI_DEBUG: "0", NO_COLOR: "1" }, + }); + let stdout = ""; + const stderr: string[] = []; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8"))); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk.toString("utf8"))); + const closed = new Promise((resolve) => { + child.on("close", (code) => resolve(code)); + }); + + child.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "it" } }, + })}\n`, + ); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" })}\n`); + child.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "impact", arguments: { file: "src/authentication/login.ts" } }, + })}\n`, + ); + child.stdin.end(); + + const code = await closed; + expect(code).toBe(0); + const responses = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + expect(responses).toHaveLength(3); + const initialize = responses[0]!.result as { + serverInfo: { name: string }; + protocolVersion: string; + }; + expect(initialize.serverInfo.name).toBe("ripple"); + expect(initialize.protocolVersion).toBe("2025-06-18"); + const tools = (responses[1]!.result as { tools: Array<{ name: string }> }).tools.map( + (tool) => tool.name, + ); + expect(tools).toEqual(["impact", "dependents", "risk", "gate_status"]); + const impact = responses[2]!.result as { + content: Array<{ text: string }>; + isError?: boolean; + }; + expect(impact.isError).toBeUndefined(); + const payload = JSON.parse(impact.content[0]!.text) as { + file: string; + risk: { score: number; level: string }; + summary: { affectedFiles: number }; + }; + expect(payload.file).toBe("src/authentication/login.ts"); + expect(payload.risk.level).toBe("MEDIUM"); + expect(payload.summary.affectedFiles).toBe(7); + expect(stderr.join("")).toBe(""); + }); + + it("turns a failed tool call into an isError result and keeps serving", { timeout }, async () => { + const child = spawn(process.execPath, [jitiCli, bin, "mcp"], { + cwd: basicFixture, + env: { ...process.env, JITI_DEBUG: "0", NO_COLOR: "1" }, + }); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8"))); + const closed = new Promise((resolve) => { + child.on("close", (code) => resolve(code)); + }); + + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "risk", arguments: { file: "src/does-not-exist.ts" } } })}\n`, + ); + child.stdin.end(); + + const code = await closed; + expect(code).toBe(0); + const response = JSON.parse(stdout.trim()) as { + result: { isError: boolean; content: Array<{ text: string }> }; + }; + expect(response.result.isError).toBe(true); + expect(response.result.content[0]!.text).toContain("not found in the project graph"); + }); +}); diff --git a/tests/unit/mcp/server.test.ts b/tests/unit/mcp/server.test.ts new file mode 100644 index 0000000..8539212 --- /dev/null +++ b/tests/unit/mcp/server.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { McpServer } from "../../../src/mcp/server.js"; +import type { McpTool } from "../../../src/mcp/server.js"; + +const echoTool: McpTool = { + name: "echo", + description: "Echo a message", + inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, + handler: (args) => ({ text: `echo: ${String(args.text)}` }), +}; + +const boomTool: McpTool = { + name: "boom", + description: "Always fails", + inputSchema: { type: "object" }, + handler: () => { + throw new Error("boom exploded"); + }, +}; + +function server(): McpServer { + return new McpServer({ + name: "ripple", + version: "0.7.0", + tools: [echoTool, boomTool], + }); +} + +function parse(line: string | null): Record | null { + return line ? (JSON.parse(line) as Record) : null; +} + +describe("McpServer protocol", () => { + it("answers initialize with server info and tool capability", async () => { + const response = parse( + await server().handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test" } }, + }), + ), + ); + expect(response!.id).toBe(1); + expect(response!.result).toMatchObject({ + protocolVersion: "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "ripple", version: "0.7.0" }, + }); + }); + + it("answers ping with an empty result", async () => { + const response = parse( + await server().handleLine(JSON.stringify({ jsonrpc: "2.0", id: 7, method: "ping" })), + ); + expect(response!.result).toEqual({}); + }); + + it("lists tools with name, description and input schema", async () => { + const response = parse( + await server().handleLine(JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" })), + ); + const tools = (response!.result as { tools: Array> }).tools; + expect(tools.map((tool) => tool.name)).toEqual(["echo", "boom"]); + expect(tools[0]).toMatchObject({ + description: "Echo a message", + inputSchema: { type: "object" }, + }); + }); + + it("calls a tool and returns its text content", async () => { + const response = parse( + await server().handleLine( + JSON.stringify({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "echo", arguments: { text: "hi" } }, + }), + ), + ); + expect(response!.result).toEqual({ + content: [{ type: "text", text: "echo: hi" }], + }); + }); + + it("returns a tool failure as isError without killing the session", async () => { + const mcp = server(); + const failure = parse( + await mcp.handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "boom" } }), + ), + ); + expect((failure!.result as { isError: boolean }).isError).toBe(true); + const next = parse( + await mcp.handleLine(JSON.stringify({ jsonrpc: "2.0", id: 5, method: "ping" })), + ); + expect(next!.result).toEqual({}); + }); + + it("rejects an unknown tool with a JSON-RPC error", async () => { + const response = parse( + await server().handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 6, method: "tools/call", params: { name: "nope" } }), + ), + ); + expect(response!.error).toMatchObject({ code: -32603 }); + expect((response!.error as { message: string }).message).toContain("Unknown tool"); + }); + + it("responds with a JSON-RPC error for unknown methods", async () => { + const response = parse( + await server().handleLine( + JSON.stringify({ jsonrpc: "2.0", id: 8, method: "resources/list" }), + ), + ); + expect(response!.error).toMatchObject({ code: -32603 }); + }); + + it("responds with a parse error for invalid JSON", async () => { + const response = parse(await server().handleLine("not json")); + expect(response!.error).toMatchObject({ code: -32700 }); + }); + + it("responds with an invalid request error for malformed messages", async () => { + const response = parse(await server().handleLine(JSON.stringify({ hello: "world" }))); + expect(response!.error).toMatchObject({ code: -32600 }); + }); + + it("ignores notifications and the initialized notification", async () => { + const mcp = server(); + expect( + await mcp.handleLine(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })), + ).toBeNull(); + expect( + await mcp.handleLine( + JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: {} }), + ), + ).toBeNull(); + }); +}); diff --git a/tests/unit/mcp/tools.test.ts b/tests/unit/mcp/tools.test.ts new file mode 100644 index 0000000..63283c0 --- /dev/null +++ b/tests/unit/mcp/tools.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { fixturePath } from "../../helpers/fixtures.js"; +import { loadProjectContext } from "../../../src/config/loader.js"; +import { runPipeline } from "../../../src/commands/pipeline.js"; +import { createMcpTools, type ProjectSnapshot } from "../../../src/mcp/tools.js"; +import type { McpToolResult } from "../../../src/mcp/server.js"; + +const aliasesFixture = fixturePath("aliases"); + +async function snapshot(): Promise { + const context = await loadProjectContext(aliasesFixture); + const { graph, entryPoints } = await runPipeline(context); + return { context, graph, entryPoints }; +} + +function tools( + snapshot: ProjectSnapshot, + getChanged?: (base?: string) => ReturnType, +) { + return createMcpTools({ + cwd: aliasesFixture, + loadProject: async () => snapshot, + getChanged: + getChanged ?? + (() => { + throw new Error("No git base ref found (tried origin/main, main, HEAD~1)"); + }), + }); +} + +function byName(tools: ReturnType, name: string) { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) throw new Error(`missing tool ${name}`); + return tool; +} + +async function call( + tool: ReturnType[number], + args: Record, +): Promise { + return tool.handler(args); +} + +describe("ripple mcp tools", () => { + const snapPromise = snapshot(); + + it("exposes the four planned tools with schemas", async () => { + const snap = await snapPromise; + const names = tools(snap).map((tool) => tool.name); + expect(names).toEqual(["impact", "dependents", "risk", "gate_status"]); + for (const tool of tools(snap)) { + expect(tool.description.length).toBeGreaterThan(20); + expect(tool.inputSchema.type).toBe("object"); + } + }); + + it("impact reports the blast radius of a file", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "impact"), { file: "app/hello.ts" }); + expect(result.isError).toBeUndefined(); + const payload = JSON.parse(result.text) as { + file: string; + risk: { score: number; level: string }; + summary: { affectedFiles: number; maxDepth: number; confidence: number }; + }; + expect(payload.file).toBe("app/hello.ts"); + expect(payload.summary.affectedFiles).toBe(1); + expect(payload.summary.maxDepth).toBe(1); + expect(payload.risk.level).toBeDefined(); + expect(payload.risk.score).toBeGreaterThanOrEqual(0); + }); + + it("impact accepts a maxDepth cap", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "impact"), { + file: "app/hello.ts", + maxDepth: 1, + }); + expect(result.isError).toBeUndefined(); + }); + + it("impact fails cleanly for missing arguments", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "impact"), {}); + expect(result.isError).toBe(true); + expect(result.text).toContain('Missing required argument "file"'); + }); + + it("impact fails cleanly for unknown files", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "impact"), { file: "src/nope.ts" }); + expect(result.isError).toBe(true); + expect(result.text).toContain("not found in the project graph"); + }); + + it("dependents lists direct dependents by default", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "dependents"), { file: "app/hello.ts" }); + const payload = JSON.parse(result.text) as { + depth: number; + count: number; + dependents: Array<{ path: string; depth: number; direct: boolean }>; + }; + expect(payload.depth).toBe(1); + expect(payload.count).toBe(1); + expect(payload.dependents[0]).toMatchObject({ + path: "src/index.ts", + depth: 1, + direct: true, + }); + }); + + it("risk returns the score with a factor breakdown", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "risk"), { file: "app/hello.ts" }); + const payload = JSON.parse(result.text) as { + score: number; + level: string; + targetInCycle: boolean; + factors: Array<{ name: string; label: string; contribution: number }>; + }; + expect(payload.score).toBeGreaterThanOrEqual(0); + expect(payload.level).toMatch(/^(LOW|MEDIUM|HIGH|CRITICAL)$/); + expect(Array.isArray(payload.factors)).toBe(true); + expect(payload.targetInCycle).toBe(false); + }); + + it("gate_status reports the change set verdict against the gate", async () => { + const snap2 = await snapshot(); + const mcpTools = tools(snap2, () => ({ + baseLabel: "origin/main", + files: ["src/index.ts"], + })); + const result = await call(byName(mcpTools, "gate_status"), { gate: "critical" }); + const payload = JSON.parse(result.text) as { + base: string; + changedFiles: number; + files: Array<{ file: string; analyzed: boolean; level: string }>; + counts: Record; + gate: { level: string; blocked: boolean; verdict: string }; + }; + expect(payload.base).toBe("origin/main"); + expect(payload.changedFiles).toBe(1); + expect(payload.files[0]).toMatchObject({ file: "src/index.ts", analyzed: true }); + expect(payload.gate).toMatchObject({ level: "critical", verdict: "pass" }); + expect(payload.counts).toHaveProperty("low"); + }); + + it("gate_status reports the gate verdict even with a default base", async () => { + const snap2 = await snapshot(); + const mcpTools = tools(snap2, () => ({ + baseLabel: "HEAD", + files: ["src/index.ts"], + })); + const result = await call(byName(mcpTools, "gate_status"), {}); + const payload = JSON.parse(result.text) as { + gate: { level: string; verdict: string }; + }; + expect(payload.gate.level).toBe("high"); + }); + + it("gate_status fails cleanly outside a git repository", async () => { + const snap = await snapPromise; + const result = await call(byName(tools(snap), "gate_status"), {}); + expect(result.isError).toBe(true); + expect(result.text).toContain("git"); + }); +}); From f14dea561e06cd27cd40ea38f69857b2071f85cd Mon Sep 17 00:00:00 2001 From: Ali Sher Date: Tue, 18 Aug 2026 13:29:58 +0500 Subject: [PATCH 4/4] perf(cache): schema 2 freshness (mtime+size, parallel stats) and benchmark harness - cache entries carry size+mtimeMs; unchanged files hit on stat alone (no read, no hash), stats run in parallel, and the cache is only rewritten when something changed - RIPPLE_TRACE=1 per-stage timings for pipeline/cache/graph profiling - scripts/bench.mjs: seeded synthetic layered projects (200..2000 files), cold vs warm, determinism check; BENCHMARKS.md documents methodology - eslint: node globals for scripts/*.mjs --- .gitignore | 2 + BENCHMARKS.md | 60 ++++++++++ CHANGELOG.md | 10 ++ README.md | 14 +-- eslint.config.mjs | 10 ++ scripts/bench.mjs | 199 ++++++++++++++++++++++++++++++++ src/analyzer/categorize.ts | 4 + src/cache/parsed.ts | 103 +++++++++++++---- src/commands/pipeline.ts | 12 +- src/graph/build.ts | 8 ++ tests/unit/cache/parsed.test.ts | 34 +++++- 11 files changed, 427 insertions(+), 29 deletions(-) create mode 100644 BENCHMARKS.md create mode 100644 scripts/bench.mjs diff --git a/.gitignore b/.gitignore index a90c747..2160826 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ coverage/ *.docx .ripple/ +.bench/ +*.cpuprofile diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..de6d389 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,60 @@ +# Benchmarks + +Ripple ships an incremental parse cache (`.ripple/cache/`). These numbers +quantify what that buys in practice: repeated runs on an unchanged codebase +skip re-parsing, and output stays byte-identical between cold and warm runs. + +## How to reproduce + +```sh +pnpm build +node scripts/bench.mjs # sizes 200..2000, 3 repeats +node scripts/bench.mjs --repeats 5 # more repeats for stabler medians +node scripts/bench.mjs --files 2000 # single size +``` + +## Methodology + +- **Synthetic layered projects** (`core → shared → domain → features → app`) + generated by `scripts/bench.mjs` with a seeded PRNG (seed 42), so every + run regenerates the same code. Real repos were deliberately not used so + results stay reproducible for anyone, anywhere. +- **Cold run**: `RIPPLE_NO_CACHE=1` — full re-parse, cache untouched. +- **Warm run**: normal invocation against the cache written by the cold run. +- Timing is wall-clock of the spawned process (median of N runs), measured + against the built bundle (`dist/bin.js`), never the dev loader. +- **Determinism**: every cold/warm pair must produce byte-identical JSON + (excluding `durationMs`) for the run to count; any drift shows as `NO`. + +## Results + +Machine: Windows 11 desktop (reference point only — expect noise on any +machine; the ratios and the determinism guarantee are the stable parts). + +| files | command | cold (ms) | warm (ms) | speedup | +| ----: | :------ | --------: | --------: | ------: | +| 200 | graph | 1458 | 1214 | 1.20x | +| 200 | analyze | 1425 | 1207 | 1.18x | +| 500 | graph | 1660 | 1292 | 1.28x | +| 500 | analyze | 1778 | 1291 | 1.38x | +| 1000 | graph | 2045 | 1372 | 1.49x | +| 1000 | analyze | 1885 | 1224 | 1.54x | +| 2000 | graph | 2666 | 1634 | 1.63x | +| 2000 | analyze | 2509 | 1449 | 1.73x | + +`analyze` beats `graph` because the impact traversal is near-free once the +graph is built; the win grows with repo size, which is exactly where +parse-time matters. + +## Notes and caveats + +- Small projects (≤500 files) show little absolute gain: process startup + (Node + bundle load) dominates both runs. The cache is worth less on a + repo Ripple can parse in ~1.5 s anyway. +- Absolute numbers are machine-local. CI sandboxes, antivirus scans and + cold page caches add noise — run `--repeats 5` before comparing. +- The cache is invalidated when discovery config (`include`/`ignore`) + changes; aliases/tsconfig changes re-run resolution but reuse parsing. +- `RIPPLE_TRACE=1` prints per-stage timings (`ts-project`, `discover`, + `cache.load-cache`, `cache.freshness+parse`, `cache.save`, `graph.*`) + for profiling a single run. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5898f8b..923817c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). the merge gate). Point any MCP client at `ripple mcp` to risk-check refactors before they happen. +### Changed + +- Cache freshness is checked with a cheap mtime+size match (file stats now + run in parallel) instead of re-hashing every file, and the cache is only + rewritten when something changed — repeated runs on an untouched tree get + ~1.2x–1.7x faster with growing repo size instead of rewriting state. + Benchmark methodology and numbers: `BENCHMARKS.md`. +- `RIPPLE_TRACE=1` prints per-stage timings (pipeline, cache, graph) for + profiling single runs. + ## [0.6.0] - 2026-08-13 ### Added diff --git a/README.md b/README.md index 8add3fd..ffd05fb 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ ripple [options] | `diff` | `-j, --json` | Emit the JSON report instead of the terminal report | | `diff` | `-b, --base ` | Git ref to diff against (default: `origin/main`, `main`, `HEAD~1`, or `diff.base` from config) | | `diff` | `-g, --gate ` | Blocking level: `medium`, `high`, or `critical` (default: `high`, or `diff.gate` from config) | -| `diff` | `-f, --format ` | Output: `terminal`, `json`, `github` (workflow annotations), or `sarif` | +| `diff` | `-f, --format ` | Output: `terminal`, `json`, `github` (workflow annotations), or `sarif` | | `diff` | `-d, --depth ` | Cap reverse traversal per file | | `diff` | `-c, --config ` | Use a specific config file | | `diff` | `--no-color` | Disable ANSI colors | @@ -376,12 +376,12 @@ the agent can check a file's blast radius before touching it: Four tools are served: -| Tool | Inputs | Returns | -| -------------- | ------------------------- | ----------------------------------------------------------------------- | -| `impact` | `file`, `maxDepth?` | Blast radius: affected files, routes, tests, components, risk level | -| `dependents` | `file`, `depth?` | Who imports the file, up to a depth (default 1, direct) | -| `risk` | `file` | Risk score with the factor breakdown behind it | -| `gate_status` | `base?`, `gate?` | Current change set vs the merge gate: files, levels, pass/block verdict | +| Tool | Inputs | Returns | +| ------------- | ------------------- | ----------------------------------------------------------------------- | +| `impact` | `file`, `maxDepth?` | Blast radius: affected files, routes, tests, components, risk level | +| `dependents` | `file`, `depth?` | Who imports the file, up to a depth (default 1, direct) | +| `risk` | `file` | Risk score with the factor breakdown behind it | +| `gate_status` | `base?`, `gate?` | Current change set vs the merge gate: files, levels, pass/block verdict | A typical agent loop: `impact src/auth/session.ts` before refactoring, `dependents` to see who breaks, then `gate_status` after editing to confirm diff --git a/eslint.config.mjs b/eslint.config.mjs index 745728f..3c40fd4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,16 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, prettier, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + performance: "readonly", + }, + }, + }, { files: ["**/*.{ts,tsx}"], languageOptions: { diff --git a/scripts/bench.mjs b/scripts/bench.mjs new file mode 100644 index 0000000..612eca3 --- /dev/null +++ b/scripts/bench.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * Synthetic benchmark generator and runner for Ripple. + * + * Generates layered TypeScript projects (core -> shared -> domain -> + * features -> app) with a seeded PRNG so every run is reproducible, then + * measures cold vs warm analysis against the built `dist` binary: + * + * node scripts/bench.mjs # default matrix (200..2000 files) + * node scripts/bench.mjs --files 500 # single size + * node scripts/bench.mjs --repeats 5 # more repeats (default 3) + * + * Numbers are machine-local; run `pnpm build` first so timings reflect the + * real bundle, not the jiti dev loader. + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const distBin = path.join(repoRoot, "dist", "bin.js"); +const workDir = path.join(repoRoot, ".bench"); + +const LAYERS = [ + { name: "core", share: 0.25 }, + { name: "shared", share: 0.15 }, + { name: "domain", share: 0.25 }, + { name: "features", share: 0.25 }, + { name: "app", share: 0.1 }, +]; + +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function buildLayers(count) { + const layers = LAYERS.map((layer) => ({ ...layer, files: [] })); + let remaining = count; + for (let i = 0; i < LAYERS.length; i++) { + const layer = LAYERS[i]; + const n = i === LAYERS.length - 1 ? remaining : Math.round(count * layer.share); + for (let j = 0; j < n; j++) { + layers[i].files.push({ name: `${layer.name}/m${String(j).padStart(4, "0")}.ts` }); + } + remaining -= n; + } + return layers; +} + +function generate(dir, count) { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync( + path.join(dir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "Bundler", + strict: true, + }, + }, + null, + 2, + ), + ); + writeFileSync( + path.join(dir, "ripple.config.json"), + JSON.stringify({ include: ["src/**/*.ts"] }, null, 2), + ); + + const rand = mulberry32(42); + const layers = buildLayers(count); + const byName = new Map(); + for (const layer of layers) { + mkdirSync(path.join(dir, "src", layer.name), { recursive: true }); + for (const file of layer.files) byName.set(file.name, file); + } + + const pick = (pool, k) => { + const picked = []; + const copy = [...pool]; + for (let i = 0; i < k && copy.length > 0; i++) { + picked.push(copy.splice(Math.floor(rand() * copy.length), 1)[0]); + } + return picked; + }; + + for (let i = 0; i < layers.length; i++) { + const layer = layers[i]; + for (const file of layer.files) { + const deps = i === 0 ? [] : pick(layers[i - 1].files, 2 + Math.floor(rand() * 2)); + const exports = []; + const fromDir = path.dirname(file.name); + const imports = deps.map((dep, k) => { + const symbol = `dep${k}`; + exports.push(`${symbol}${k}`); + const rel = path.relative(fromDir, dep.name).replace(/\\/g, "/"); + return `import { ${symbol}${k} } from "./${rel.replace(/\.ts$/, "")}";`; + }); + const lines = [ + ...imports, + ...exports.map((symbol, k) => `export const ${symbol}${k} = ${k + 1};`), + `export const value${file.name.replace(/\W/g, "")} = ${Math.floor(rand() * 1000)};`, + ]; + writeFileSync(path.join(dir, "src", file.name), `${lines.join("\n")}\n`); + } + } + + const appFiles = layers[layers.length - 1].files.map((file) => file.name); + const entryImports = appFiles + .map((name, k) => `import { dep0${k} } from "./${name.replace(/\.ts$/, "")}";`) + .join("\n"); + writeFileSync(path.join(dir, "src", "index.ts"), `${entryImports}\nexport const app = 1;\n`); +} + +function runOnce(cwd, args, cold) { + const env = { ...process.env, NO_COLOR: "1" }; + if (cold) env.RIPPLE_NO_CACHE = "1"; + const started = performance.now(); + const out = execFileSync(process.execPath, [distBin, ...args], { + cwd, + env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { ms: Math.round(performance.now() - started), out }; +} + +function digestWithoutDuration(out) { + const doc = JSON.parse(out); + delete doc.durationMs; + return createHash("sha256").update(JSON.stringify(doc)).digest("hex").slice(0, 12); +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +const args = process.argv.slice(2); +const filesFlag = args.indexOf("--files"); +const sizes = filesFlag >= 0 ? [Number(args[filesFlag + 1])] : [200, 500, 1000, 2000]; +const repeatsFlag = args.indexOf("--repeats"); +const REPEATS = repeatsFlag >= 0 ? Number(args[repeatsFlag + 1]) : 3; +const keep = args.includes("--keep"); + +if (!existsSync(distBin)) { + console.error("dist/bin.js not found — run `pnpm build` first."); + process.exit(1); +} + +console.log(`Benchmarking ${distBin} — sizes: ${sizes.join(", ")} — repeats: ${REPEATS}\n`); +console.log("size | command | cold (ms) | warm (ms) | speedup | deterministic"); + +for (const size of sizes) { + const dir = path.join(workDir, `proj-${size}`); + generate(dir, size); + + for (const [command, label] of [ + [["graph", "--json"], "graph"], + [["analyze", "src/features/m0000.ts", "--json"], "analyze"], + ]) { + const coldRuns = []; + const warmRuns = []; + let deterministic = true; + let referenceHash = ""; + for (let r = 0; r < REPEATS; r++) { + const cold = runOnce(dir, command, true); + coldRuns.push(cold.ms); + const coldHash = digestWithoutDuration(cold.out); + const warm = runOnce(dir, command, false); + warmRuns.push(warm.ms); + const warmHash = digestWithoutDuration(warm.out); + if (r === 0) referenceHash = coldHash; + if (coldHash !== referenceHash || warmHash !== referenceHash) deterministic = false; + } + const coldMs = median(coldRuns); + const warmMs = median(warmRuns); + const speedup = (coldMs / Math.max(warmMs, 1)).toFixed(2); + console.log( + `${String(size).padEnd(4)} | ${label.padEnd(9)} | ${String(coldMs).padStart(6)} | ${String(warmMs).padStart(6)} | ${speedup}x | ${deterministic ? "yes" : "NO"}`, + ); + } +} + +if (!keep) { + rmSync(workDir, { recursive: true, force: true }); +} diff --git a/src/analyzer/categorize.ts b/src/analyzer/categorize.ts index 885c8e8..a88c225 100644 --- a/src/analyzer/categorize.ts +++ b/src/analyzer/categorize.ts @@ -112,6 +112,7 @@ export function createCategorizer(options: CategorizerOptions): (filePath: strin * entry file names (in the project root or `src/`). */ export async function detectEntryPoints(rootDir: string): Promise> { + const started = Date.now(); const entryPoints = new Set(); const packageJsonPath = await findPackageJson(rootDir); @@ -133,5 +134,8 @@ export async function detectEntryPoints(rootDir: string): Promise> { } } + if (process.env.RIPPLE_TRACE === "1") { + console.error(`[trace] detectEntryPoints: ${Date.now() - started}ms`); + } return entryPoints; } diff --git a/src/cache/parsed.ts b/src/cache/parsed.ts index 177cb2a..b1528c7 100644 --- a/src/cache/parsed.ts +++ b/src/cache/parsed.ts @@ -11,20 +11,22 @@ import { toPosix } from "../utils/paths.js"; * Incremental parse cache. * * Parsing a file with ts-morph (imports, exports, symbols) is the dominant - * cost of an analysis run. For every discovered file we instead compute a - * cheap content hash; when the hash matches the cached one, the previously - * parsed surface is reused and ts-morph never touches the file. + * cost of an analysis run. For every discovered file we reuse the previously + * parsed surface when the file is provably unchanged. Freshness is checked + * with a cheap mtime+size match first — the steady-state hot path touches + * neither the file contents nor the hasher — and the cache is only rewritten + * to disk when something actually changed, so repeated runs are near-free. * * The cache is keyed by the discovery-affecting config (include/ignore), so - * changing discovery invalidates it wholesale. The parsed surface is only - * a function of file content — resolution, cycles and risk are recomputed + * changing discovery invalidates it wholesale. The parsed surface is only a + * function of file content — resolution, cycles and risk are recomputed * fresh on every run, so cached runs are byte-identical to cold runs. * * The cache lives in `/.ripple/cache/` and is written atomically. */ -export const CACHE_SCHEMA = 1; -export const CACHE_FILE = path.join(".ripple", "cache", "parsed-v1.json"); +export const CACHE_SCHEMA = 2; +export const CACHE_FILE = path.join(".ripple", "cache", "parsed-v2.json"); /** * Set `RIPPLE_NO_CACHE=1` to force a cold run (e.g. for benchmarks). The @@ -32,9 +34,14 @@ export const CACHE_FILE = path.join(".ripple", "cache", "parsed-v1.json"); */ export const cacheEnabled = (): boolean => process.env.RIPPLE_NO_CACHE !== "1"; -/** One cached file: its content hash plus the parsed surface. */ +/** One cached file: freshness metadata plus the parsed surface. */ export interface ParsedCacheEntry { + /** Content hash; the source of truth when mtime+size don't match. */ hash: string; + /** File size in bytes at cache time (fast freshness check). */ + size: number; + /** File mtime in ms at cache time (fast freshness check). */ + mtimeMs: number; parsed: ParsedFile; } @@ -72,7 +79,7 @@ function cachePath(rootDir: string): string { return path.join(rootDir, CACHE_FILE); } -/** Load the cache manifest; any problem (missing, corrupt, stale) → empty. */ +/** Load the cache manifest; any problem (missing, corrupt, stale) → empty. */ export async function loadParsedCache( rootDir: string, configHash: string, @@ -131,6 +138,9 @@ export interface ParsedCacheStats { * Load parsed surfaces for every discovered file, parsing only the files * whose content changed since the last run. The result is ordered exactly * like `filePaths`, so downstream output is identical to a cold run. + * + * When nothing changed, the on-disk cache is not rewritten at all — the + * steady-state hot path is stat + JSON parse + graph rebuild. */ export async function loadParsedFiles(options: { project: Project; @@ -147,25 +157,61 @@ export async function loadParsedFiles(options: { } const configHash = configCacheHash(config); const cached = await loadParsedCache(rootDir, configHash); + const trace = process.env.RIPPLE_TRACE === "1"; + const traceAt = (label: string, from: number): number => { + if (trace) console.error(`[trace] cache.${label}: ${Date.now() - from}ms`); + return Date.now(); + }; + let stage = Date.now(); + const states = await Promise.all( + filePaths.map(async (abs) => { + try { + const stat = await fs.stat(abs); + return { abs, rel: relPosix(rootDir, abs), size: stat.size, mtimeMs: stat.mtimeMs }; + } catch { + return { abs, rel: relPosix(rootDir, abs), size: -1, mtimeMs: -1 }; + } + }), + ); + stage = traceAt("load-cache", stage); const hits = new Map(); const stale: string[] = []; const parsed = new Map(); + /** Set when an entry needs persisting (miss, mtime refresh, or drift). */ + let dirty = false; - for (const abs of filePaths) { - const rel = relPosix(rootDir, abs); - let hash: string; + for (const state of states) { + const entry = cached.get(state.rel); + if ( + entry !== undefined && + entry.size === state.size && + entry.mtimeMs === state.mtimeMs && + state.mtimeMs > 0 + ) { + hits.set(state.rel, entry); + parsed.set(state.rel, { ...entry.parsed, path: state.abs }); + continue; + } + + let hash = ""; try { - hash = await contentHash(abs); + hash = await contentHash(state.abs); } catch { - hash = ""; + /* unreadable file: hash stays "" and the entry is re-parsed */ } - const entry = cached.get(rel); + if (entry !== undefined && entry.hash === hash) { - hits.set(rel, entry); - parsed.set(rel, { ...entry.parsed, path: abs }); + if (entry.mtimeMs !== state.mtimeMs || entry.size !== state.size) { + hits.set(state.rel, { ...entry, size: state.size, mtimeMs: state.mtimeMs }); + dirty = true; + } else { + hits.set(state.rel, entry); + } + parsed.set(state.rel, { ...entry.parsed, path: state.abs }); } else { - stale.push(abs); + stale.push(state.abs); + dirty = true; } } @@ -176,13 +222,30 @@ export async function loadParsedFiles(options: { for (const parsedFile of parseMany(project, stale)) { parsed.set(relPosix(rootDir, parsedFile.path), parsedFile); } + } + traceAt("freshness+parse", stage); + + if (dirty) { const next = new Map(hits); for (const parsedFile of parsed.values()) { const rel = relPosix(rootDir, parsedFile.path); - const hash = hits.get(rel)?.hash ?? (await contentHash(parsedFile.path).catch(() => "")); - next.set(rel, { hash, parsed: { ...parsedFile, path: rel } }); + if (next.has(rel)) continue; + const state = states.find((candidate) => candidate.rel === rel); + let hash = ""; + try { + hash = await contentHash(parsedFile.path); + } catch { + /* unreadable file: hash stays "" */ + } + next.set(rel, { + hash, + size: state?.size ?? -1, + mtimeMs: state?.mtimeMs ?? -1, + parsed: { ...parsedFile, path: rel }, + }); } await saveParsedCache(rootDir, configHash, next); + traceAt("save", stage); } const parsedFiles = filePaths.map((abs) => { diff --git a/src/commands/pipeline.ts b/src/commands/pipeline.ts index 8c86b3d..45a4218 100644 --- a/src/commands/pipeline.ts +++ b/src/commands/pipeline.ts @@ -29,24 +29,34 @@ export interface PipelineResult { export async function runPipeline(context: ProjectContext): Promise { const started = Date.now(); + const trace = process.env.RIPPLE_TRACE === "1"; + const mark = (label: string): void => { + if (trace) console.error(`[trace] pipeline.${label}: ${Date.now() - started}ms`); + }; const project = createTsProject(); + mark("ts-project"); const filePaths = await discoverSourceFiles({ rootDir: context.rootDir, include: context.config.include, ignore: context.config.ignore, }); - const { parsedFiles } = await loadParsedFiles({ + mark("discover"); + const { parsedFiles, stats } = await loadParsedFiles({ project, rootDir: context.rootDir, filePaths, config: context.config, }); + if (trace) console.error(`[trace] cache: ${stats.hits} hits, ${stats.misses} misses`); + mark("parsed"); const graph = buildGraphFromParsed(parsedFiles, { rootDir: context.rootDir, aliases: context.aliases, fileKeys: new Set(filePaths.map((p) => pathKey(p, context.rootDir))), }); + mark("graph"); const entryPoints = await detectEntryPoints(context.rootDir); + mark("entry-points"); return { graph, filePaths, entryPoints, durationMs: Date.now() - started }; } diff --git a/src/graph/build.ts b/src/graph/build.ts index 014dab5..0f6e4d7 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -34,6 +34,11 @@ export function buildGraphFromParsed( parsedFiles: ParsedFile[], context: ResolverContext, ): DependencyGraph { + const started = Date.now(); + const trace = process.env.RIPPLE_TRACE === "1"; + const mark = (label: string): void => { + if (trace) console.error(`[trace] graph.${label}: ${Date.now() - started}ms`); + }; const nodes: DependencyGraph["nodes"] = new Map(); const forward: DependencyGraph["forward"] = new Map(); const reverse: DependencyGraph["reverse"] = new Map(); @@ -49,6 +54,7 @@ export function buildGraphFromParsed( nodes.set(key, { path: parsed.path, parsed }); if (!parsed.parseError) parsedCount++; } + mark("nodes"); for (const parsed of parsedFiles) { const fromKey = pathKey(parsed.path, context.rootDir); @@ -99,8 +105,10 @@ export function buildGraphFromParsed( cycles: 0, }, }; + mark("edges"); graph.cycles = findCycles(graph); graph.stats.cycles = graph.cycles.length; + mark("cycles"); return graph; } diff --git a/tests/unit/cache/parsed.test.ts b/tests/unit/cache/parsed.test.ts index 03abb31..9bfd6bd 100644 --- a/tests/unit/cache/parsed.test.ts +++ b/tests/unit/cache/parsed.test.ts @@ -60,7 +60,7 @@ function fixturePaths(root: string): string[] { describe("cache location and hashing", () => { it("stores the cache under .ripple/cache in the project root", () => { - expect(CACHE_FILE).toBe(path.join(".ripple", "cache", "parsed-v1.json")); + expect(CACHE_FILE).toBe(path.join(".ripple", "cache", "parsed-v2.json")); }); it("hashes file content deterministically", async () => { @@ -101,6 +101,8 @@ describe("loadParsedCache / saveParsedCache", () => { "src/main.ts", { hash: "h1", + size: 123, + mtimeMs: 456, parsed: { path: "src/main.ts", kind: "ts" as const, @@ -134,6 +136,8 @@ describe("loadParsedCache / saveParsedCache", () => { "src/main.ts", { hash: "h", + size: 10, + mtimeMs: 20, parsed: { path: "src/main.ts", kind: "ts" as const, @@ -274,4 +278,32 @@ describe("loadParsedFiles", () => { else process.env.RIPPLE_NO_CACHE = previous; } }); + + it("serves hits for files touched without content changes", async () => { + const root = fixtureRoot("ripple-cache-touch-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + + const touched = path.join(root, "src", "main.ts"); + const now = new Date(); + fs.utimesSync(touched, now, now); + + const warm = await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(warm.stats.hits).toBe(filePaths.length); + expect(warm.stats.misses).toBe(0); + }); + + it("does not rewrite the cache when nothing changed", async () => { + const root = fixtureRoot("ripple-cache-clean-"); + const filePaths = fixturePaths(root); + const config = minimalConfig(); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + + const cachePath = path.join(root, CACHE_FILE); + const before = fs.statSync(cachePath).mtimeMs; + await new Promise((resolve) => setTimeout(resolve, 20)); + await loadParsedFiles({ project, rootDir: root, filePaths, config }); + expect(fs.statSync(cachePath).mtimeMs).toBe(before); + }); });