@alimaandev/ripple 0.2.0 → 0.4.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
 
@@ -93,7 +97,7 @@ Everything below is real output against a shipped example project in `tests/fixt
93
97
  ```bash
94
98
  npm install -g @alimaandev/ripple
95
99
 
96
- ripple version # → 0.1.1
100
+ ripple version # → 0.4.0
97
101
  ```
98
102
 
99
103
  Or install from source:
@@ -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`, or `diff.base` from config) |
233
+ | `diff` | `-g, --gate <level>` | Blocking level: `medium`, `high`, or `critical` (default: `high`, or `diff.gate` from config) |
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
@@ -266,6 +321,10 @@ defaults — every field is optional.
266
321
  "cycleMembership": 0.1
267
322
  },
268
323
  "thresholds": { "medium": 30, "high": 55, "critical": 80 }
324
+ },
325
+ "diff": {
326
+ "base": "origin/main",
327
+ "gate": "high"
269
328
  }
270
329
  }
271
330
  ```
@@ -278,9 +337,14 @@ defaults — every field is optional.
278
337
  | `tsconfigPath` | `tsconfig.json` | tsconfig to read for `paths` |
279
338
  | `risk.weights` | see example | Risk signal weights |
280
339
  | `risk.thresholds` | 30 / 55 / 80 | Score thresholds for MEDIUM / HIGH / CRITICAL |
340
+ | `diff.base` | `origin/main` → `main` → `HEAD~1` | Git ref to diff against when `--base` is not given |
341
+ | `diff.gate` | `high` | Blocking level when `--gate` is not given |
281
342
 
282
343
  `node_modules` is always excluded, regardless of config.
283
344
 
345
+ The `diff` block sets command defaults only — `--base`/`--gate` flags always
346
+ win over the config file, which wins over the built-in defaults.
347
+
284
348
  ## Risk scoring
285
349
 
286
350
  The score is a weighted sum of seven signals, normalized to 0–1. Affected
@@ -379,6 +443,39 @@ error-tolerant recovery, `graph/` resolves specifiers and detects cycles, and
379
443
  > change without a major version bump. `graph --json` follows the same
380
444
  > convention.
381
445
 
446
+ ### `ripple diff --json`
447
+
448
+ The diff report wraps a per-file view of the same risk model, plus the gate
449
+ verdict:
450
+
451
+ ```json
452
+ {
453
+ "tool": "ripple",
454
+ "version": "0.3.0",
455
+ "command": "diff",
456
+ "base": "origin/main",
457
+ "changedFiles": 2,
458
+ "files": [
459
+ {
460
+ "file": "src/auth/token.ts",
461
+ "analyzed": true,
462
+ "risk": { "score": 88.2, "level": "CRITICAL", "factors": [] },
463
+ "affectedFiles": 14,
464
+ "targetInCycle": false
465
+ },
466
+ { "file": "src/api/legacy.js", "analyzed": false }
467
+ ],
468
+ "gate": {
469
+ "level": "high",
470
+ "blocked": true,
471
+ "counts": { "low": 0, "medium": 0, "high": 0, "critical": 1 }
472
+ },
473
+ "durationMs": 118
474
+ }
475
+ ```
476
+
477
+ `blocked` tells CI everything: exit code `1` if `true`, `0` otherwise.
478
+
382
479
  ### Use it in CI
383
480
 
384
481
  ```bash
@@ -386,6 +483,16 @@ ripple analyze src/authentication/login.ts --json | jq -r .risk.level
386
483
  # MEDIUM
387
484
  ```
388
485
 
486
+ Gate every changed file in one call:
487
+
488
+ ```bash
489
+ #!/usr/bin/env bash
490
+ set -euo pipefail
491
+
492
+ ripple diff --json --gate high
493
+ # exits 1 when any changed file is HIGH or CRITICAL
494
+ ```
495
+
389
496
  ```bash
390
497
  #!/usr/bin/env bash
391
498
  set -euo pipefail
@@ -430,12 +537,12 @@ fi
430
537
 
431
538
  ## Exit codes
432
539
 
433
- | Code | Meaning |
434
- | ---- | ------------------------- |
435
- | `0` | Success |
436
- | `1` | General failure |
437
- | `2` | Target file not found |
438
- | `3` | Config missing or invalid |
540
+ | Code | Meaning |
541
+ | ---- | ------------------------------------- |
542
+ | `0` | Success |
543
+ | `1` | General failure / gate blocked |
544
+ | `2` | Target file or git base ref not found |
545
+ | `3` | Config missing or invalid |
439
546
 
440
547
  ## Development
441
548
 
@@ -461,6 +568,10 @@ npm install -g @alimaandev/ripple
461
568
  ripple analyze src/your-file.ts --json | jq -r .risk.level # MEDIUM, HIGH, ...
462
569
  ```
463
570
 
571
+ If Ripple has ever kept you from an accidental breaking change, a
572
+ [little star](https://github.com/alimaandev/ripple) goes a long way — it
573
+ helps other developers find it.
574
+
464
575
  ## License
465
576
 
466
577
  MIT — see [LICENSE](LICENSE).
package/dist/bin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { DEFAULT_CONFIG, rippleConfigSchema, formatSchemaIssues } from './chunk-OOERV74D.js';
2
+ import { DEFAULT_CONFIG, rippleConfigSchema, formatSchemaIssues } from './chunk-LDCFNORX.js';
3
3
  import { CommanderError, Command } from 'commander';
4
4
  import { createJiti } from 'jiti';
5
5
  import path8 from 'path';
@@ -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,224 @@ 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 started = Date.now();
1838
+ const context = await loadProjectContext(ctx2.cwd, options.config);
1839
+ const gate = options.gate ?? context.config.diff.gate ?? "high";
1840
+ const base = options.base ?? context.config.diff.base;
1841
+ tracker.next("Resolving git changes");
1842
+ const changed = changedFiles(ctx2.cwd, base);
1843
+ tracker.next("Analyzing changed files");
1844
+ const { graph, entryPoints } = await runPipeline(context);
1845
+ const byRel = /* @__PURE__ */ new Map();
1846
+ const skipped = [];
1847
+ for (const rel of changed.files) {
1848
+ const abs = path8.resolve(ctx2.cwd, rel);
1849
+ const key = pathKey(abs, context.rootDir);
1850
+ if (!graph.nodes.has(key)) {
1851
+ skipped.push(rel);
1852
+ continue;
1853
+ }
1854
+ byRel.set(
1855
+ rel,
1856
+ analyzeFile({
1857
+ graph,
1858
+ context,
1859
+ entryPoints,
1860
+ targetKey: key,
1861
+ targetPath: abs,
1862
+ durationMs: 0,
1863
+ ...options.depth !== void 0 ? { maxDepth: options.depth } : {}
1864
+ })
1865
+ );
1866
+ }
1867
+ tracker.done();
1868
+ const results = [...byRel.values()].sort((a, b) => b.risk.score - a.risk.score);
1869
+ const counts = countLevels(results);
1870
+ const blocked = results.some((result) => LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate]);
1871
+ const durationMs = Date.now() - started;
1872
+ if (options.json) {
1873
+ ctx2.writer.write(
1874
+ serializeJson(
1875
+ buildDiffJsonReport(
1876
+ changed.baseLabel,
1877
+ changed.files,
1878
+ byRel,
1879
+ gate,
1880
+ blocked,
1881
+ counts,
1882
+ durationMs,
1883
+ ctx2.version
1884
+ )
1885
+ )
1886
+ );
1887
+ return blocked ? ExitCode.Failure : ExitCode.Success;
1888
+ }
1889
+ renderDiffReport(
1890
+ {
1891
+ cwd: ctx2.cwd,
1892
+ baseLabel: changed.baseLabel,
1893
+ files: changed.files,
1894
+ results,
1895
+ skipped,
1896
+ counts,
1897
+ gate,
1898
+ blocked,
1899
+ durationMs,
1900
+ style
1901
+ },
1902
+ ctx2.writer
1903
+ );
1904
+ return blocked ? ExitCode.Failure : ExitCode.Success;
1905
+ }
1906
+ function renderDiffReport(input, writer) {
1907
+ const { cwd, baseLabel, files, results, skipped, counts, gate, blocked, durationMs, style } = input;
1908
+ writer.writeLine(brandHeader({ label: `diff vs ${baseLabel}`, style }));
1909
+ writer.writeLine();
1910
+ const rows = [
1911
+ ["Changed", pluralize(files.length, "file")],
1912
+ ["Source", pluralize(results.length, "file")],
1913
+ ["Duration", dim(formatDuration(durationMs), style)]
1914
+ ];
1915
+ const width = Math.max(...rows.map(([key]) => key.length));
1916
+ for (const [key, value] of rows) {
1917
+ writer.writeLine(`${key.padEnd(width)} ${value}`);
1918
+ }
1919
+ writer.writeLine();
1920
+ const mark = blocked ? icon("cross") : icon("tick");
1921
+ const color = blocked ? chalk.red : chalk.green;
1922
+ const markText = style.color ? color(mark) : mark;
1923
+ const verdict = blocked ? "Gate blocked" : "Gate passed";
1924
+ const verdictText = style.color ? color(strong(verdict, style)) : verdict;
1925
+ writer.writeLine(
1926
+ `${markText} ${verdictText} \u2014 ${riskySummary(counts, gate)} ${dim(`(${VERDICT_NOTE[gate]})`, style)}`
1927
+ );
1928
+ writer.writeLine();
1929
+ writer.writeLine(sectionHeader(`Risk analysis (${results.length} files)`, style));
1930
+ for (const result of results) {
1931
+ const risky = LEVEL_INDEX[result.risk.level] >= GATE_INDEX[gate];
1932
+ const fileIcon = risky ? icon("cross") : icon("tick");
1933
+ const fileIconText = style.color ? risky ? chalk.red(fileIcon) : chalk.green(fileIcon) : fileIcon;
1934
+ const score = result.risk.score.toFixed(1);
1935
+ const gauge = riskGauge(result.risk.score, result.risk.level, style);
1936
+ const file = displayPath(result.targetPath, cwd);
1937
+ writer.writeLine(
1938
+ `${fileIconText} ${file} ${riskBadge(result.risk.level, style)} \xB7 ${score}/100${gauge ? ` ${gauge}` : ""}`
1939
+ );
1940
+ }
1941
+ for (const rel of skipped) {
1942
+ writer.writeLine(
1943
+ `${dim(icon("ellipsis"), style)} ${dim(rel, style)} ${dim("(not a source file)", style)}`
1944
+ );
1945
+ }
1946
+ writer.writeLine();
1947
+ }
1948
+ function buildDiffJsonReport(baseLabel, changedFileNames, byRel, gate, blocked, counts, durationMs, version) {
1949
+ const files = [];
1950
+ for (const rel of changedFileNames) {
1951
+ const result = byRel.get(rel);
1952
+ if (result) {
1953
+ files.push({
1954
+ file: rel,
1955
+ analyzed: true,
1956
+ risk: result.risk,
1957
+ affectedFiles: result.summary.affectedFiles,
1958
+ targetInCycle: result.targetInCycle
1959
+ });
1960
+ } else {
1961
+ files.push({ file: rel, analyzed: false });
1962
+ }
1963
+ }
1964
+ files.sort((a, b) => (b.risk?.score ?? -1) - (a.risk?.score ?? -1));
1965
+ return {
1966
+ tool: "ripple",
1967
+ version,
1968
+ command: "diff",
1969
+ base: baseLabel,
1970
+ changedFiles: changedFileNames.length,
1971
+ files,
1972
+ gate: { level: gate, blocked, counts },
1973
+ durationMs
1974
+ };
1975
+ }
1757
1976
 
1758
1977
  // src/cli/program.ts
1759
1978
  var PROGRAM_NAME = "ripple";
@@ -1792,6 +2011,27 @@ function createProgram(ctx2) {
1792
2011
  throw new ExitError(exitCode2);
1793
2012
  }
1794
2013
  });
2014
+ 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(
2015
+ "-g, --gate <level>",
2016
+ "risk level that blocks the gate (medium | high | critical)",
2017
+ parseGateLevel
2018
+ ).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) => {
2019
+ const exitCode2 = await diffCommand(
2020
+ {
2021
+ json: Boolean(options.json),
2022
+ verbose: Boolean(options.verbose),
2023
+ ...options.base !== void 0 ? { base: options.base } : {},
2024
+ ...options.gate !== void 0 ? { gate: options.gate } : {},
2025
+ ...options.depth !== void 0 ? { depth: options.depth } : {},
2026
+ ...options.config !== void 0 ? { config: options.config } : {},
2027
+ color: options.color
2028
+ },
2029
+ ctx2
2030
+ );
2031
+ if (exitCode2 !== ExitCode.Success) {
2032
+ throw new ExitError(exitCode2);
2033
+ }
2034
+ });
1795
2035
  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
2036
  const exitCode2 = await doctorCommand(
1797
2037
  {
@@ -1843,6 +2083,16 @@ function parsePositiveInt(value) {
1843
2083
  }
1844
2084
  return parsed;
1845
2085
  }
2086
+ function parseGateLevel(value) {
2087
+ if (value === "medium" || value === "high" || value === "critical") {
2088
+ return value;
2089
+ }
2090
+ throw new CommanderError(
2091
+ 1,
2092
+ "commander.invalidArgument",
2093
+ `expected one of medium | high | critical, got "${value}"`
2094
+ );
2095
+ }
1846
2096
  function printError(error, ctx2) {
1847
2097
  if (error instanceof RippleError) {
1848
2098
  ctx2.errorWriter.writeLine(`\u2716 ${error.message}`);
@@ -1876,7 +2126,7 @@ async function run({ argv, ctx: ctx2 }) {
1876
2126
 
1877
2127
  // package.json
1878
2128
  var package_default = {
1879
- version: "0.2.0"};
2129
+ version: "0.4.0"};
1880
2130
 
1881
2131
  // src/cli/bin.ts
1882
2132
  var ctx = {