@alimaandev/ripple 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,10 @@ Change less. Break less. Know the blast radius of any file before you touch it.
16
16
  &nbsp;<a href="package.json"><img alt="Node.js >= 22" src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?logo=nodedotjs&logoColor=white&style=for-the-badge"/></a>&nbsp;
17
17
  </p>
18
18
 
19
- <pre><code>npm install -g @alimaandev/ripple</code></pre>
19
+ <pre><code>npm install -g @alimaandev/ripple
20
+
21
+ # or try it without installing anything:
22
+ npx @alimaandev/ripple analyze src/your-file.ts</code></pre>
20
23
 
21
24
  <img src="https://raw.githubusercontent.com/alimaandev/ripple/main/assets/hero.svg" alt="Terminal output of `ripple analyze` — risk, affected files, routes, components, tests, and confidence score" title="ripple analyze" width="100%" />
22
25
 
@@ -76,6 +79,7 @@ Just deterministic analysis against the code you actually have.
76
79
  | **Categorized results** | Routes, layouts, components, utilities, and tests detected by path conventions — not a raw string of file names. |
77
80
  | **Entry-point intelligence** | `package.json` `main`/`bin` plus conventional root and `src` entry files, flagged in the report. |
78
81
  | **Stable JSON contract** | A machine-readable report for CI gates, dashboards, and tooling — additive-only compatibility. |
82
+ | **Change-set gating** | `ripple diff` analyzes every changed file since a base ref and blocks the merge on risky code. |
79
83
  | **Error-tolerant parsing** | A broken file lowers confidence instead of aborting the run. No false precision. |
80
84
  | **Deterministic & offline** | Same input, same output, every time. No hidden network dependency. |
81
85
 
@@ -108,8 +112,17 @@ npm link # exposes the global `ripple` binary
108
112
 
109
113
  ### Run your first analysis
110
114
 
115
+ No install required — `npx` pulls the package from the registry on the fly:
116
+
111
117
  ```bash
112
118
  cd tests/fixtures/basic
119
+ npx @alimaandev/ripple analyze src/authentication/login.ts
120
+ ```
121
+
122
+ Once you're sold, install it globally:
123
+
124
+ ```bash
125
+ npm install -g @alimaandev/ripple
113
126
  ripple analyze src/authentication/login.ts
114
127
  ```
115
128
 
@@ -192,28 +205,35 @@ ripple <command> [options]
192
205
  | ---------------- | ------------------------------------------------- |
193
206
  | `analyze <file>` | Impact analysis for a target file |
194
207
  | `graph [file]` | Project stats, or a single file's dependency tree |
208
+ | `diff` | Gate all files changed since a git base ref |
195
209
  | `doctor` | Project and environment health check |
196
210
  | `init` | Scaffold a `ripple.config.json` |
197
211
  | `version` | Print the current version |
198
212
 
199
213
  ### Options
200
214
 
201
- | Command | Flag | Description |
202
- | --------- | --------------------- | ---------------------------------------------------------------- |
203
- | `analyze` | `-j, --json` | Emit the JSON report instead of the terminal report |
204
- | `analyze` | `-v, --verbose` | Include the risk-factor point breakdown |
205
- | `analyze` | `-d, --depth <n>` | Cap the reverse traversal at `n` levels |
206
- | `analyze` | `-c, --config <path>` | Use a specific config file |
207
- | `analyze` | `--no-color` | Disable ANSI colors |
208
- | `graph` | `-j, --json` | Emit the JSON report |
209
- | `graph` | `-r, --reverse` | Show dependents (what imports this file) instead of dependencies |
210
- | `graph` | `-d, --depth <n>` | Cap tree depth |
211
- | `graph` | `-c, --config <path>` | Use a specific config file |
212
- | `graph` | `--no-color` | Disable ANSI colors |
213
- | `doctor` | `-c, --config <path>` | Use a specific config file |
214
- | `doctor` | `-v, --verbose` | Verbose output |
215
- | `doctor` | `--no-color` | Disable ANSI colors |
216
- | `init` | `-f, --force` | Overwrite an existing config |
215
+ | Command | Flag | Description |
216
+ | --------- | --------------------- | ------------------------------------------------------------------ |
217
+ | `analyze` | `-j, --json` | Emit the JSON report instead of the terminal report |
218
+ | `analyze` | `-v, --verbose` | Include the risk-factor point breakdown |
219
+ | `analyze` | `-d, --depth <n>` | Cap the reverse traversal at `n` levels |
220
+ | `analyze` | `-c, --config <path>` | Use a specific config file |
221
+ | `analyze` | `--no-color` | Disable ANSI colors |
222
+ | `graph` | `-j, --json` | Emit the JSON report |
223
+ | `graph` | `-r, --reverse` | Show dependents (what imports this file) instead of dependencies |
224
+ | `graph` | `-d, --depth <n>` | Cap tree depth |
225
+ | `graph` | `-c, --config <path>` | Use a specific config file |
226
+ | `graph` | `--no-color` | Disable ANSI colors |
227
+ | `doctor` | `-c, --config <path>` | Use a specific config file |
228
+ | `doctor` | `-v, --verbose` | Verbose output |
229
+ | `doctor` | `--no-color` | Disable ANSI colors |
230
+ | `init` | `-f, --force` | Overwrite an existing config |
231
+ | `diff` | `-j, --json` | Emit the JSON report instead of the terminal report |
232
+ | `diff` | `-b, --base <ref>` | Git ref to diff against (default: `origin/main`, `main`, `HEAD~1`) |
233
+ | `diff` | `-g, --gate <level>` | Blocking level: `medium`, `high`, or `critical` (default: `high`) |
234
+ | `diff` | `-d, --depth <n>` | Cap reverse traversal per file |
235
+ | `diff` | `-c, --config <path>` | Use a specific config file |
236
+ | `diff` | `--no-color` | Disable ANSI colors |
217
237
 
218
238
  ### `ripple doctor`
219
239
 
@@ -243,6 +263,41 @@ $ ripple doctor
243
263
 
244
264
  Writes `ripple.config.json` with the recommended defaults, ready to edit.
245
265
 
266
+ ### `ripple diff`
267
+
268
+ Analyzes every file that changed since a base git ref, scores each with the
269
+ same risk model as `analyze`, and gates the change set: any file reaching
270
+ the gate level (HIGH by default) makes the command exit `1`. Untracked
271
+ files are included; deleted files and non-source changes are skipped.
272
+
273
+ ```bash
274
+ cd my-project
275
+ ripple diff # vs origin/main (then main, then HEAD~1)
276
+ ripple diff --base HEAD~1 --json # machine-readable gate report
277
+ ripple diff --gate critical # only CRITICAL blocks the merge
278
+ ```
279
+
280
+ ```text
281
+ ╭ ripple ─────────────────────╮
282
+ │ ❯ diff vs origin/main │
283
+ ╰─────────────────────────────╯
284
+
285
+ Changed 2 files
286
+ Source 2 files
287
+ Duration 118ms
288
+
289
+ ✖ Gate blocked — 1 CRITICAL (no HIGH or CRITICAL)
290
+
291
+ ─────────────────────────────────────
292
+ Risk analysis (2 files)
293
+ ✖ src/auth/token.ts CRITICAL · 88.2/100 ████████░░
294
+ ● src/auth/session.ts LOW · 12.4/100 █░░░░░░░░░
295
+ … src/api/legacy.js (not a source file)
296
+ ```
297
+
298
+ Run `ripple diff` locally before opening a PR, and in CI as the same gate —
299
+ identical code, identical verdict.
300
+
246
301
  ## Configuration
247
302
 
248
303
  Ripple discovers `ripple.config.ts`, `.js`, `.cjs`, `.mjs`, or `.json` in the
@@ -379,6 +434,39 @@ error-tolerant recovery, `graph/` resolves specifiers and detects cycles, and
379
434
  > change without a major version bump. `graph --json` follows the same
380
435
  > convention.
381
436
 
437
+ ### `ripple diff --json`
438
+
439
+ The diff report wraps a per-file view of the same risk model, plus the gate
440
+ verdict:
441
+
442
+ ```json
443
+ {
444
+ "tool": "ripple",
445
+ "version": "0.3.0",
446
+ "command": "diff",
447
+ "base": "origin/main",
448
+ "changedFiles": 2,
449
+ "files": [
450
+ {
451
+ "file": "src/auth/token.ts",
452
+ "analyzed": true,
453
+ "risk": { "score": 88.2, "level": "CRITICAL", "factors": [] },
454
+ "affectedFiles": 14,
455
+ "targetInCycle": false
456
+ },
457
+ { "file": "src/api/legacy.js", "analyzed": false }
458
+ ],
459
+ "gate": {
460
+ "level": "high",
461
+ "blocked": true,
462
+ "counts": { "low": 0, "medium": 0, "high": 0, "critical": 1 }
463
+ },
464
+ "durationMs": 118
465
+ }
466
+ ```
467
+
468
+ `blocked` tells CI everything: exit code `1` if `true`, `0` otherwise.
469
+
382
470
  ### Use it in CI
383
471
 
384
472
  ```bash
@@ -386,6 +474,16 @@ ripple analyze src/authentication/login.ts --json | jq -r .risk.level
386
474
  # MEDIUM
387
475
  ```
388
476
 
477
+ Gate every changed file in one call:
478
+
479
+ ```bash
480
+ #!/usr/bin/env bash
481
+ set -euo pipefail
482
+
483
+ ripple diff --json --gate high
484
+ # exits 1 when any changed file is HIGH or CRITICAL
485
+ ```
486
+
389
487
  ```bash
390
488
  #!/usr/bin/env bash
391
489
  set -euo pipefail
@@ -430,12 +528,12 @@ fi
430
528
 
431
529
  ## Exit codes
432
530
 
433
- | Code | Meaning |
434
- | ---- | ------------------------- |
435
- | `0` | Success |
436
- | `1` | General failure |
437
- | `2` | Target file not found |
438
- | `3` | Config missing or invalid |
531
+ | Code | Meaning |
532
+ | ---- | ------------------------------------- |
533
+ | `0` | Success |
534
+ | `1` | General failure / gate blocked |
535
+ | `2` | Target file or git base ref not found |
536
+ | `3` | Config missing or invalid |
439
537
 
440
538
  ## Development
441
539
 
@@ -461,6 +559,10 @@ npm install -g @alimaandev/ripple
461
559
  ripple analyze src/your-file.ts --json | jq -r .risk.level # MEDIUM, HIGH, ...
462
560
  ```
463
561
 
562
+ If Ripple has ever kept you from an accidental breaking change, a
563
+ [little star](https://github.com/alimaandev/ripple) goes a long way — it
564
+ helps other developers find it.
565
+
464
566
  ## License
465
567
 
466
568
  MIT — see [LICENSE](LICENSE).
package/dist/bin.js CHANGED
@@ -12,6 +12,7 @@ import treeify from 'treeify';
12
12
  import boxen from 'boxen';
13
13
  import fg from 'fast-glob';
14
14
  import ora from 'ora';
15
+ import { execFileSync } from 'child_process';
15
16
 
16
17
  // src/output/writer.ts
17
18
  var TerminalWriter = class {
@@ -268,8 +269,8 @@ async function loadProjectContext(cwd, explicitConfigPath) {
268
269
  function cycleMemberPaths(graph) {
269
270
  const members = /* @__PURE__ */ new Set();
270
271
  for (const cycle of graph.cycles) {
271
- for (const path12 of cycle.members) {
272
- members.add(path12);
272
+ for (const path13 of cycle.members) {
273
+ members.add(path13);
273
274
  }
274
275
  }
275
276
  return members;
@@ -980,12 +981,12 @@ function renderGraphReport(input, options, writer) {
980
981
  writer.writeLine();
981
982
  if (input.forward) {
982
983
  writer.writeLine(sectionHeader("Dependants (forward)", style));
983
- writer.writeLine(renderTree(input.forward.map((path12) => ({ label: path12 }))));
984
+ writer.writeLine(renderTree(input.forward.map((path13) => ({ label: path13 }))));
984
985
  writer.writeLine();
985
986
  }
986
987
  if (input.reverse) {
987
988
  writer.writeLine(sectionHeader("Dependents (reverse)", style));
988
- writer.writeLine(renderTree(input.reverse.map((path12) => ({ label: path12 }))));
989
+ writer.writeLine(renderTree(input.reverse.map((path13) => ({ label: path13 }))));
989
990
  writer.writeLine();
990
991
  }
991
992
  writer.writeLine(sectionHeader("Circular dependencies", style));
@@ -1277,8 +1278,8 @@ function findCycles(graph) {
1277
1278
  }
1278
1279
  const members = [...component].sort();
1279
1280
  const componentSet = new Set(component);
1280
- const path12 = representativeCyclePath(graph, component, componentSet);
1281
- cycles.push({ members, path: path12 ?? [members[0], members[0]] });
1281
+ const path13 = representativeCyclePath(graph, component, componentSet);
1282
+ cycles.push({ members, path: path13 ?? [members[0], members[0]] });
1282
1283
  }
1283
1284
  cycles.sort(
1284
1285
  (a, b) => a.members.length - b.members.length || a.members[0].localeCompare(b.members[0])
@@ -1287,14 +1288,14 @@ function findCycles(graph) {
1287
1288
  }
1288
1289
  function representativeCyclePath(graph, component, componentSet) {
1289
1290
  const inPath = /* @__PURE__ */ new Set();
1290
- const path12 = [];
1291
+ const path13 = [];
1291
1292
  let current = component[0];
1292
1293
  for (let steps = 0; steps <= component.length * 2 && current !== void 0; steps++) {
1293
1294
  const firstOccurrence = inPath.has(current);
1294
1295
  if (firstOccurrence) {
1295
- const start = path12.indexOf(current);
1296
+ const start = path13.indexOf(current);
1296
1297
  if (start === -1) return void 0;
1297
- const cycle = path12.slice(start);
1298
+ const cycle = path13.slice(start);
1298
1299
  if (cycle.length > 1 || graph.forward.get(current)?.has(current)) {
1299
1300
  cycle.push(current);
1300
1301
  return cycle;
@@ -1302,7 +1303,7 @@ function representativeCyclePath(graph, component, componentSet) {
1302
1303
  return void 0;
1303
1304
  }
1304
1305
  inPath.add(current);
1305
- path12.push(current);
1306
+ path13.push(current);
1306
1307
  const next = [...graph.forward.get(current) ?? []].find(
1307
1308
  (candidate) => componentSet.has(candidate)
1308
1309
  );
@@ -1754,6 +1755,223 @@ async function initCommand(options, ctx2) {
1754
1755
  return ExitCode.Failure;
1755
1756
  }
1756
1757
  }
1758
+ var DEFAULT_BASES = ["origin/main", "main", "HEAD~1"];
1759
+ function git(args, cwd) {
1760
+ return execFileSync("git", args, {
1761
+ cwd,
1762
+ encoding: "utf8",
1763
+ stdio: ["ignore", "pipe", "pipe"]
1764
+ }).trim();
1765
+ }
1766
+ function refExists(ref, cwd) {
1767
+ try {
1768
+ const out = git(["rev-parse", "--verify", ref], cwd);
1769
+ return out.length > 0;
1770
+ } catch {
1771
+ return false;
1772
+ }
1773
+ }
1774
+ function resolveBase(cwd, requested) {
1775
+ if (requested) {
1776
+ if (!refExists(requested, cwd)) {
1777
+ throw new RippleError(`Git ref not found: ${requested}`, ExitCode.NotFound);
1778
+ }
1779
+ return requested;
1780
+ }
1781
+ for (const candidate of DEFAULT_BASES) {
1782
+ if (refExists(candidate, cwd)) return candidate;
1783
+ }
1784
+ throw new RippleError(
1785
+ "No git base ref found (tried origin/main, main, HEAD~1). Pass --base <ref> explicitly.",
1786
+ ExitCode.NotFound
1787
+ );
1788
+ }
1789
+ function changedFiles(cwd, base) {
1790
+ const baseLabel = resolveBase(cwd, base);
1791
+ let baseCommit = baseLabel;
1792
+ try {
1793
+ const mergeBase = git(["merge-base", baseLabel, "HEAD"], cwd);
1794
+ if (mergeBase) baseCommit = mergeBase;
1795
+ } catch {
1796
+ }
1797
+ const files = /* @__PURE__ */ new Set();
1798
+ const tracked = git(["diff", "--name-only", "--diff-filter=ACMRT", baseCommit], cwd);
1799
+ for (const line of tracked.split("\n")) {
1800
+ if (line.trim()) files.add(line.trim());
1801
+ }
1802
+ const untracked = git(["ls-files", "--others", "--exclude-standard"], cwd);
1803
+ for (const line of untracked.split("\n")) {
1804
+ if (line.trim()) files.add(line.trim());
1805
+ }
1806
+ return { baseLabel, files: [...files].sort() };
1807
+ }
1808
+
1809
+ // src/commands/diff.ts
1810
+ var LEVEL_INDEX = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
1811
+ var GATE_INDEX = { medium: 1, high: 2, critical: 3 };
1812
+ var VERDICT_NOTE = {
1813
+ medium: "no MEDIUM or worse",
1814
+ high: "no HIGH or CRITICAL",
1815
+ critical: "no CRITICAL"
1816
+ };
1817
+ function countLevels(results) {
1818
+ const counts = { low: 0, medium: 0, high: 0, critical: 0 };
1819
+ for (const result of results) {
1820
+ counts[result.risk.level.toLowerCase()] += 1;
1821
+ }
1822
+ return counts;
1823
+ }
1824
+ function riskySummary(counts, gate) {
1825
+ const gateIndex = GATE_INDEX[gate];
1826
+ const parts = [];
1827
+ if (LEVEL_INDEX.CRITICAL >= gateIndex && counts.critical > 0)
1828
+ parts.push(`${counts.critical} CRITICAL`);
1829
+ if (LEVEL_INDEX.HIGH >= gateIndex && counts.high > 0) parts.push(`${counts.high} HIGH`);
1830
+ if (LEVEL_INDEX.MEDIUM >= gateIndex && counts.medium > 0) parts.push(`${counts.medium} MEDIUM`);
1831
+ if (LEVEL_INDEX.LOW >= gateIndex && counts.low > 0) parts.push(`${counts.low} LOW`);
1832
+ return parts.length > 0 ? parts.join(" \xB7 ") : "none";
1833
+ }
1834
+ async function diffCommand(options, ctx2) {
1835
+ const style = { color: resolveColor(options.color) };
1836
+ const tracker = createStageTracker(!options.json);
1837
+ const gate = options.gate ?? "high";
1838
+ const started = Date.now();
1839
+ tracker.next("Resolving git changes");
1840
+ const changed = changedFiles(ctx2.cwd, options.base);
1841
+ tracker.next("Analyzing changed files");
1842
+ const context = await loadProjectContext(ctx2.cwd, options.config);
1843
+ const { graph, entryPoints } = await runPipeline(context);
1844
+ const byRel = /* @__PURE__ */ new Map();
1845
+ const skipped = [];
1846
+ for (const rel of changed.files) {
1847
+ const abs = path8.resolve(ctx2.cwd, rel);
1848
+ const key = pathKey(abs, context.rootDir);
1849
+ if (!graph.nodes.has(key)) {
1850
+ skipped.push(rel);
1851
+ continue;
1852
+ }
1853
+ byRel.set(
1854
+ rel,
1855
+ analyzeFile({
1856
+ graph,
1857
+ context,
1858
+ entryPoints,
1859
+ targetKey: key,
1860
+ targetPath: abs,
1861
+ durationMs: 0,
1862
+ ...options.depth !== void 0 ? { maxDepth: options.depth } : {}
1863
+ })
1864
+ );
1865
+ }
1866
+ tracker.done();
1867
+ const results = [...byRel.values()].sort((a, b) => b.risk.score - a.risk.score);
1868
+ const counts = countLevels(results);
1869
+ const blocked = results.some((result) => LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate]);
1870
+ const durationMs = Date.now() - started;
1871
+ if (options.json) {
1872
+ ctx2.writer.write(
1873
+ serializeJson(
1874
+ buildDiffJsonReport(
1875
+ changed.baseLabel,
1876
+ changed.files,
1877
+ byRel,
1878
+ gate,
1879
+ blocked,
1880
+ counts,
1881
+ durationMs,
1882
+ ctx2.version
1883
+ )
1884
+ )
1885
+ );
1886
+ return blocked ? ExitCode.Failure : ExitCode.Success;
1887
+ }
1888
+ renderDiffReport(
1889
+ {
1890
+ cwd: ctx2.cwd,
1891
+ baseLabel: changed.baseLabel,
1892
+ files: changed.files,
1893
+ results,
1894
+ skipped,
1895
+ counts,
1896
+ gate,
1897
+ blocked,
1898
+ durationMs,
1899
+ style
1900
+ },
1901
+ ctx2.writer
1902
+ );
1903
+ return blocked ? ExitCode.Failure : ExitCode.Success;
1904
+ }
1905
+ function renderDiffReport(input, writer) {
1906
+ const { cwd, baseLabel, files, results, skipped, counts, gate, blocked, durationMs, style } = input;
1907
+ writer.writeLine(brandHeader({ label: `diff vs ${baseLabel}`, style }));
1908
+ writer.writeLine();
1909
+ const rows = [
1910
+ ["Changed", pluralize(files.length, "file")],
1911
+ ["Source", pluralize(results.length, "file")],
1912
+ ["Duration", dim(formatDuration(durationMs), style)]
1913
+ ];
1914
+ const width = Math.max(...rows.map(([key]) => key.length));
1915
+ for (const [key, value] of rows) {
1916
+ writer.writeLine(`${key.padEnd(width)} ${value}`);
1917
+ }
1918
+ writer.writeLine();
1919
+ const mark = blocked ? icon("cross") : icon("tick");
1920
+ const color = blocked ? chalk.red : chalk.green;
1921
+ const markText = style.color ? color(mark) : mark;
1922
+ const verdict = blocked ? "Gate blocked" : "Gate passed";
1923
+ const verdictText = style.color ? color(strong(verdict, style)) : verdict;
1924
+ writer.writeLine(
1925
+ `${markText} ${verdictText} \u2014 ${riskySummary(counts, gate)} ${dim(`(${VERDICT_NOTE[gate]})`, style)}`
1926
+ );
1927
+ writer.writeLine();
1928
+ writer.writeLine(sectionHeader(`Risk analysis (${results.length} files)`, style));
1929
+ for (const result of results) {
1930
+ const risky = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
1931
+ const fileIcon = risky ? icon("cross") : icon("tick");
1932
+ const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
1933
+ const score = result.risk.score.toFixed(1);
1934
+ const gauge = riskGauge(result.risk.score, result.risk.level, style);
1935
+ const file = displayPath(result.targetPath, cwd);
1936
+ writer.writeLine(
1937
+ `${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}`
1938
+ );
1939
+ }
1940
+ for (const rel of skipped) {
1941
+ writer.writeLine(
1942
+ `${dim(icon("ellipsis"), style)} ${dim(rel, style)} ${dim("(not a source file)", style)}`
1943
+ );
1944
+ }
1945
+ writer.writeLine();
1946
+ }
1947
+ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked, counts, durationMs, version) {
1948
+ const files = [];
1949
+ for (const rel of changedFileNames) {
1950
+ const result = byRel.get(rel);
1951
+ if (result) {
1952
+ files.push({
1953
+ file: rel,
1954
+ analyzed: true,
1955
+ risk: result.risk,
1956
+ affectedFiles: result.summary.affectedFiles,
1957
+ targetInCycle: result.targetInCycle
1958
+ });
1959
+ } else {
1960
+ files.push({ file: rel, analyzed: false });
1961
+ }
1962
+ }
1963
+ files.sort((a, b) => (b.risk?.score ?? -1) - (a.risk?.score ?? -1));
1964
+ return {
1965
+ tool: "ripple",
1966
+ version,
1967
+ command: "diff",
1968
+ base: baseLabel,
1969
+ changedFiles: changedFileNames.length,
1970
+ files,
1971
+ gate: { level: gate, blocked, counts },
1972
+ durationMs
1973
+ };
1974
+ }
1757
1975
 
1758
1976
  // src/cli/program.ts
1759
1977
  var PROGRAM_NAME = "ripple";
@@ -1792,6 +2010,27 @@ function createProgram(ctx2) {
1792
2010
  throw new ExitError(exitCode2);
1793
2011
  }
1794
2012
  });
2013
+ program.command("diff").description("Analyze changed files since a base ref and gate risky changes").option("-j, --json", "emit a machine-readable JSON report").option("-v, --verbose", "include extra sections").option("-b, --base <ref>", "git ref to diff against (default: origin/main, main, HEAD~1)").option(
2014
+ "-g, --gate <level>",
2015
+ "risk level that blocks the gate (medium | high | critical)",
2016
+ parseGateLevel
2017
+ ).option("-d, --depth <number>", "cap the reverse traversal depth", parsePositiveInt).option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
2018
+ const exitCode2 = await diffCommand(
2019
+ {
2020
+ json: Boolean(options.json),
2021
+ verbose: Boolean(options.verbose),
2022
+ ...options.base !== void 0 ? { base: options.base } : {},
2023
+ ...options.gate !== void 0 ? { gate: options.gate } : {},
2024
+ ...options.depth !== void 0 ? { depth: options.depth } : {},
2025
+ ...options.config !== void 0 ? { config: options.config } : {},
2026
+ color: options.color
2027
+ },
2028
+ ctx2
2029
+ );
2030
+ if (exitCode2 !== ExitCode.Success) {
2031
+ throw new ExitError(exitCode2);
2032
+ }
2033
+ });
1795
2034
  program.command("doctor").description("Check the project and environment for Ripple readiness").option("-v, --verbose", "print detailed diagnostics").option("-c, --config <path>", "path to a ripple config file").option("--no-color", "disable ANSI colors").action(async (options) => {
1796
2035
  const exitCode2 = await doctorCommand(
1797
2036
  {
@@ -1843,6 +2082,16 @@ function parsePositiveInt(value) {
1843
2082
  }
1844
2083
  return parsed;
1845
2084
  }
2085
+ function parseGateLevel(value) {
2086
+ if (value === "medium" || value === "high" || value === "critical") {
2087
+ return value;
2088
+ }
2089
+ throw new CommanderError(
2090
+ 1,
2091
+ "commander.invalidArgument",
2092
+ `expected one of medium | high | critical, got "${value}"`
2093
+ );
2094
+ }
1846
2095
  function printError(error, ctx2) {
1847
2096
  if (error instanceof RippleError) {
1848
2097
  ctx2.errorWriter.writeLine(`\u2716 ${error.message}`);
@@ -1876,7 +2125,7 @@ async function run({ argv, ctx: ctx2 }) {
1876
2125
 
1877
2126
  // package.json
1878
2127
  var package_default = {
1879
- version: "0.2.0"};
2128
+ version: "0.3.0"};
1880
2129
 
1881
2130
  // src/cli/bin.ts
1882
2131
  var ctx = {