renovate-mcp 0.7.0 → 0.9.2

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
@@ -22,7 +22,7 @@ An MCP server for designing [Renovate](https://github.com/renovatebot/renovate)
22
22
 
23
23
  ## What it does
24
24
 
25
- Ten tools plus a preset reference:
25
+ Eleven tools plus a preset reference:
26
26
 
27
27
  | Tool | Purpose |
28
28
  | --- | --- |
@@ -30,12 +30,13 @@ Ten tools plus a preset reference:
30
30
  | `get_version` | Report the renovate-mcp server version and whether it's a released build (running from `node_modules`) or a local/dev build (typically launched via `command: node` against a checkout). |
31
31
  | `read_config` | Locate and parse a repo's Renovate config (`renovate.json`, `renovate.json5`, `.renovaterc*`, `package.json#renovate`, …) in priority order. |
32
32
  | `resolve_config` | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true`. |
33
+ | `explain_config` | Inverse of `resolve_config`: walk the same preset tree but annotate every leaf field with the chain of presets that touched it. Each leaf is `{ value, setBy }` where `setBy` lists every contribution in merge order — last entry wins for scalars; for arrays each entry adds its own slice. Same offline-by-default behaviour and same `externalPresets` / `endpoint` / `platform` opt-ins as `resolve_config`. |
33
34
  | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — shows file/line hits and extracted dep info. Offline. |
34
35
  | `validate_config` | Run `renovate-config-validator` against a file or inline object. |
35
36
  | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses — malformed `/…/` regex patterns plus unknown manager names in `matchManagers` / `excludeManagers`. Offline. |
36
37
  | `dry_run` | Run Renovate with `--dry-run` and return the structured JSON report. Defaults to `--platform=local` against `repoPath`; pass `platform` + `endpoint` + `token` + `repository` to run as a real GitHub/GitLab client (needed when the config extends `local>` presets on a private host). When `platform` is not passed, the tool reads `RENOVATE_PLATFORM` from the MCP server's env before defaulting to `local`. When `token` is not passed, falls back to `RENOVATE_TOKEN` from MCP env, then to `GITLAB_TOKEN` (when `platform=gitlab`) or `GITHUB_TOKEN` (when `platform=github`) — auto-translated to `RENOVATE_TOKEN` for the spawned Renovate CLI, since Renovate itself only reads that one var. `repository` accepts GitHub-style `owner/repo` and GitLab nested-group paths like `group/subgroup/project`. `endpoint`/`token` also flow through in local mode so `gitlab>…` / `github>…` preset shortcuts can be redirected at a self-hosted host without setting up a full remote run. No PRs, no pushes. Emits MCP progress notifications during the run when the caller supplies a `progressToken`. |
37
38
  | `dry_run_diff` | Semantic diff between two `dry_run` reports — `added` / `removed` / `changed` proposed updates plus a compact text rendering. Stateless; takes both reports as inputs. Useful when iterating on a config to see exactly what each tweak did. |
38
- | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true`. |
39
+ | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true` is paired with `confirmForce: "YES_OVERRIDE_VALIDATION"` — the literal sentinel makes accidental overrides under prompt-injected tool calls harder. |
39
40
  | `renovate://presets` (resource) | Markdown index of all 1000+ built-in presets grouped by namespace. |
40
41
  | `renovate://presets/{namespace}` (resource template) | Markdown listing for a single namespace (e.g. `renovate://presets/config`) — cheaper than the full index. |
41
42
  | `renovate://preset/{name}` (resource template) | Expanded JSON body for one preset (e.g. `renovate://preset/config:recommended`). |
@@ -46,13 +47,14 @@ See [Design notes](#design-notes) for implementation details (timeouts, safety c
46
47
 
47
48
  - **Not a Renovate replacement.** This server doesn't open PRs, run scheduled updates, or execute in CI — it's a design-time companion for a local `renovate.json`. Use the real Renovate for the actual dependency-update pipeline.
48
49
  - **`resolve_config` is preview-quality.** Preset expansion runs against a committed snapshot, and template substitution implements only positional `{{argN}}` placeholders — non-positional tokens and Handlebars helpers are flagged in `warnings` and pass through verbatim. For authoritative output, run `dry_run`.
49
- - **`preview_custom_manager` is a subset of Renovate's regex manager.** It covers `customType: "regex"`, `matchStringsStrategy: "any"`, and `{{groupName}}` template substitution only — other custom types, other strategies, and full Handlebars (helpers, conditionals) are not implemented. Use it for fast regex iteration; confirm with `dry_run`.
50
+ - **`preview_custom_manager` is a subset of Renovate's regex manager.** It covers `customType: "regex"`, `matchStringsStrategy` of `any` / `combination` / `recursive`, and `{{groupName}}` template substitution only — other custom types and full Handlebars (helpers, conditionals) are not implemented. Use it for fast regex iteration; confirm with `dry_run`.
50
51
  - **`validate_config` / `dry_run` aren't exercised in CI.** CI deliberately doesn't install Renovate, so tools that shell out to it are only covered by unit/integration tests that don't require the binary. Run them locally against the Renovate install you intend to deploy with.
51
52
 
52
53
  ## Requirements
53
54
 
54
55
  **Required**
55
56
 
57
+ - Linux or macOS. Windows is not supported — `package.json` declares `"os": ["darwin", "linux"]`, so `npm i` surfaces an `EBADPLATFORM` warning on Windows, and the server exits with a clear stderr message at startup. Run on WSL2 or a Linux/macOS host instead.
56
58
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
57
59
  - Renovate on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` + `renovate-config-validator` via `npm exec`. Only needed for `validate_config`, `dry_run`, and `write_config`; the offline tools (`read_config`, `resolve_config`, `preview_custom_manager`, `lint_config`) work without it.
58
60
 
@@ -162,6 +164,7 @@ Once the server is wired up, try prompts like these. They're written for Claude
162
164
 
163
165
  - "Read the Renovate config in this repo and summarize what it actually does — expand every preset so I can see the real effective behavior."
164
166
  - "Resolve my config and list anything that landed in `presetsUnresolved`, with the reason for each."
167
+ - "Why is my `prCreation` set to `not-pending`? Use `explain_config` to trace which preset set it."
165
168
 
166
169
  **Browsing presets**
167
170
 
@@ -228,7 +231,11 @@ npm run generate:managers # regenerate src/data/managers.generated.ts from the r
228
231
 
229
232
  The preset catalogue at `src/data/presets.generated.ts` and the manager-name list at `src/data/managers.generated.ts` are committed snapshots of Renovate's built-in presets and manager registry. Runtime code never imports the `renovate` package — only the `scripts/generate-*.mjs` scripts do. Regenerate both after bumping the `renovate` devDependency.
230
233
 
231
- CI runs `typecheck`, `build`, and `test:coverage` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`). Coverage is uploaded as a per-run artifact; no threshold is enforced yet.
234
+ CI runs `typecheck`, `build`, and `test:coverage` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`), across an OS matrix of `ubuntu-latest` and `macos-latest` with `fail-fast: false` so a platform-specific regression on either side surfaces. Coverage is uploaded as a per-run artifact from the Ubuntu job only (to avoid name collisions); no threshold is enforced yet. The Ubuntu job also re-runs `npm install --package-lock-only` and fails on a non-empty diff, catching drift between `package.json` root metadata (`os`, `engines`, `bin`, dep ranges) and `package-lock.json` that `npm ci` does not validate.
235
+
236
+ `.github/workflows/dependency-review.yml` runs [`actions/dependency-review-action`](https://github.com/actions/dependency-review-action) on every PR and fails the check when a newly introduced dependency carries a CVE of `high` severity or above. Findings also render inline in the PR's "Files changed" / Conversation tabs.
237
+
238
+ `.github/workflows/claude.yml` is maintainer tooling: it lets the repo owner trigger [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action) by mentioning `@claude` in an issue, issue comment, PR review, or PR review comment. It's gated on `sender.login == repository_owner`, so mentions from anyone else are ignored. The workflow needs the `CLAUDE_CODE_OAUTH_TOKEN` secret on the repo; outside contributors and forks do not need any Anthropic credentials to work on this project.
232
239
 
233
240
  ## Release flow
234
241
 
@@ -237,7 +244,7 @@ Releases are automated via [release-please](https://github.com/googleapis/releas
237
244
  1. Merge Conventional Commits to `main` (`feat:`, `fix:`, etc.)
238
245
  2. release-please opens or updates a release PR that bumps the version and updates `CHANGELOG.md`
239
246
  3. Merging the release PR creates a GitHub release and tag
240
- 4. The `publish` workflow (`.github/workflows/publish.yml`) fires on the release event, runs the test suite, and runs `npm publish` with provenance
247
+ 4. The `publish` workflow (`.github/workflows/publish.yml`) fires on the release event, runs the test suite, smoke-tests the packed tarball end-to-end (install globally, then run an MCP `initialize` handshake against the resulting `renovate-mcp` binary), and only then runs `npm publish` with provenance
241
248
 
242
249
  Publishing uses [npm Trusted Publishers](https://docs.npmjs.com/trusted-publishers) — no npm token is stored in the repo. Instead the workflow authenticates to npm via OIDC using the `id-token: write` permission. Trusted Publishers must be configured on the npm package settings page before OIDC publishes will succeed; this requires the package to already exist, so the **first release must be published manually once** (`npm login && npm publish` from a clean build), after which Trusted Publishers can be wired up and all subsequent releases go through this workflow.
243
250
 
@@ -255,11 +262,15 @@ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); thi
255
262
  - `preview_custom_manager` runs every user-supplied regex on a `worker_threads` worker with a wall-clock budget per operation (default 2 s, configurable via `matchTimeoutMs`). Catastrophic backtracking — e.g. `^(a+)+b$` against `aaaa…c`, or `(.*)*=` against a modestly sized file — would otherwise pin the MCP server's event loop indefinitely. On timeout the worker is terminated and a warning is appended identifying which `fileMatch[i]` or `matchStrings[i]` was aborted, so the user can simplify the pattern or raise the budget.
256
263
  - `preview_custom_manager` also stats each matched file before reading it and skips anything larger than `maxFileBytes` (default 5 MiB) with a warning, so a stray lockfile, generated artifact, or SQL dump caught by a loose `fileMatch` can't OOM the server. `maxHitsPerFile` already bounds output size; this guards input size.
257
264
  - `resolve_config` expands `extends` against a committed snapshot of Renovate's built-in presets (`src/data/presets.generated.ts`). External `github>` / `gitlab>` fetching is opt-in, uses each platform's contents API with a 10 s timeout, and caches results per call. The `endpoint` input swaps in a custom API base for GHE / self-hosted GitLab; `platform` additionally rewrites `local>` presets to be fetched against that endpoint.
265
+ - External preset fetches refuse to follow HTTP redirects (`redirect: "manual"`) and never attach `Authorization: Bearer …` / `PRIVATE-TOKEN: …` headers to non-`https://` URLs. Following redirects with custom auth headers can leak `RENOVATE_TOKEN` / `GITHUB_TOKEN` / `GITLAB_TOKEN` cross-host (undici only strips the standard `Authorization` header on cross-*origin* redirects, not `PRIVATE-TOKEN`); rejecting them outright avoids the leak path and matches the fact that the GitHub/GitLab content APIs don't normally redirect for valid presets.
266
+ - External preset fetches cap the response body at 1 MB. A `Content-Length` over the cap is rejected up front; chunked responses without a declared length are streamed into a bounded buffer that aborts the read once the running total passes the cap. Auth-failure bodies (401/403) are read with an 8 KB cap so a misconfigured endpoint returning a giant HTML error page can't OOM the long-lived MCP process. Preset bodies are tiny in practice, so the cap is well above any legitimate value.
267
+ - The `endpoint` input on `resolve_config` / `explain_config` / `dry_run` is validated before any token-bearing request is built or forwarded to the Renovate child as `--endpoint=`. Anything that isn't an `https://` URL with a non-empty public host is refused: non-https schemes (`http:`, `file:`, `data:`, …), userinfo (`user:pass@host`), and any RFC 1918 / loopback / link-local literal (`localhost`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16` (incl. cloud-metadata), `0.0.0.0/8`, `::1`, `::`, `::ffff:*`, `fc00::/7`, `fe80::/10`). Self-hosted GitHub Enterprise / GitLab on public-DNS https URLs is unaffected. The check is string-level (no DNS) — the goal is to make a prompt-injected `endpoint` value unable to coerce a token-bearing request to an attacker-controlled or internal-only address.
258
268
  - `resolve_config` merges preset bodies with a close approximation of Renovate's own `mergeChildConfig` — arrays concat, objects recursively merge, scalars overwrite — not a bit-identical port. Rule-specific semantics for `hostRules`, `regexManagers` / `customManagers`, and certain boolean flags aren't modeled here. Every response carries `mergeQuality: "preview"` plus a human-readable `disclaimer` so callers can't miss the limitation; run `dry_run` for authoritative output.
259
269
  - When `resolve_config` encounters template tokens outside its supported subset, it records a structured entry in `warnings`: under-argument cases (`{{arg2}}` referenced when only one arg was passed) substitute an empty string, while non-positional tokens (`{{packageRules}}`, Handlebars helpers like `{{#if …}}`) pass through verbatim.
260
- - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout. When a `hostRules` input is passed it's written to a mode-0600 temp file in `os.tmpdir()`, handed to Renovate via the `RENOVATE_CONFIG_FILE` env var (the CLI has no `--config-file` flag), and deleted in a `finally` block. Token/password values — including the platform `token` input — are scrubbed from the detected `problems` list and the `logTail` fallback before returning.
270
+ - `explain_config` reuses the same expansion machinery as `resolve_config` (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so the two can't drift — if `resolve_config` resolves a preset, `explain_config` does too. The annotated layer threads each preset body's leaves through a parallel `mergeAnnotated` that preserves contributors instead of dropping them, then pins each contribution with the source name (literal `extends` entry, or `<own>` for the user's input config) and a `via` chain naming every parent preset traversed to reach it. Arrays accumulate every contribution in `setBy` and concat their `value`s; scalars list every contribution in merge order with the last as the winner. `merge_quality: "preview"` carries the same disclaimer as `resolve_config` — Renovate's rule-specific semantics for `hostRules`, `regexManagers`, and certain boolean flags aren't modelled.
271
+ - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout. The report path is pre-created with mode 0600 before Renovate is spawned (Renovate overwrites in place with `O_TRUNC`, preserving the mode), so the on-disk JSON is never world-readable while the run is in flight — Renovate's `problems` array can carry host-rule values that we don't want exposed under the default `umask`. The file is unlinked in a `finally` block. When a `hostRules` input is passed it's written to a mode-0600 temp file in `os.tmpdir()`, handed to Renovate via the `RENOVATE_CONFIG_FILE` env var (the CLI has no `--config-file` flag), and deleted in a `finally` block. Token/password values — including the platform `token` input — are scrubbed from the detected `problems` list and the `logTail` fallback before returning.
261
272
  - `dry_run` defaults to `--platform=local` so no host token is required, but that mode can't resolve `local>` presets (they have no platform context to expand against) and silently hides non-default-host GitHub/GitLab setups. When the caller doesn't pass a `platform` input, the tool first falls back to `RENOVATE_PLATFORM` from the MCP server's env (if it's one of `local`/`github`/`gitlab`) before defaulting to `local` — without that fallback, the wrapper would unconditionally pass `--platform=local` and silently override an env var the user had set in their `mcp.json`. Passing `platform` + `endpoint` + `token` + `repository` (or relying on the `RENOVATE_PLATFORM` / `RENOVATE_ENDPOINT` env vars) switches the run to a real platform client so the preset-fetch path works end-to-end; `--dry-run` is still set, so no PRs are opened. The repository is passed to Renovate as a positional argument because the CLI has no `--repository` flag. `endpoint` and `token` are also forwarded in the default `platform=local` mode so `gitlab>…` / `github>…` preset shortcuts (which are otherwise hardcoded to gitlab.com/github.com) can be redirected at a self-hosted host. As a guard against the silent-failure mode, `dry_run` preflight-checks the repo's config: if it extends any `local>…` preset while the effective platform is `local`, the tool fails fast with specific remediation rather than spawning a Renovate run that would opaquely report `config-validation`. The preflight is skipped for `dryRunMode=extract` so manifest-only extraction can still be attempted.
262
273
  - `dry_run` returns a top-level `ok` boolean that is `false` whenever the CLI exited non-zero OR the structured report contains a validation/error-level problem (`level >= 40`, `message === "config-validation"`, or a non-empty `validationError` field). Renovate frequently writes exit-code 0 alongside report-level failures — trusting the exit code alone hides runs that did nothing.
263
274
  - `dry_run` emits MCP progress notifications only when the caller's `tools/call` includes `_meta.progressToken` — no-op otherwise, so legacy clients see zero overhead. A 5-second heartbeat ticks while the child runs; each tick's message is best-effort enriched with the latest Renovate JSON-log `msg` seen on stdout. Notifications are also emitted at start and completion. We deliberately don't couple to Renovate's log schema beyond reading `msg`, since that schema isn't a stable API.
264
275
  - `dry_run_diff` is stateless on purpose: both reports are passed as inputs, no per-repo state is kept on the server. Updates are keyed by `(manager, packageFile, depName)` so a version bump on the same dep shows up once under `changed` rather than twice as `removed + added`. Compared per identity: `newValue`, `newVersion`, `updateType`, `branchName`, `groupName`, `schedule`. Either input can be the raw Renovate report or the full `dry_run` summary (with the `report` key) — the tool unwraps `report` automatically.
265
- - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
276
+ - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk. The `force: true` escape hatch is gated behind a separate `confirmForce: "YES_OVERRIDE_VALIDATION"` literal: a single boolean is too easy for a prompt-injected agent to flip, but emitting an unusual sentinel string is a deliberate step that's harder to navigate accidentally.
@@ -3,6 +3,6 @@ export interface GeneratedPreset {
3
3
  description: string | null;
4
4
  body: Record<string, unknown>;
5
5
  }
6
- export declare const RENOVATE_VERSION = "43.139.5";
6
+ export declare const RENOVATE_VERSION = "43.141.5";
7
7
  export declare const PRESETS: Record<string, GeneratedPreset>;
8
8
  export declare const PRESET_NAMES: readonly string[];
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED by scripts/generate-presets.mjs from renovate@43.139.5.
2
2
  // Do not edit by hand. Run `npm run generate:presets` to refresh.
3
- export const RENOVATE_VERSION = "43.139.5";
3
+ export const RENOVATE_VERSION = "43.141.5";
4
4
  export const PRESETS = {
5
5
  "abandonments:recommended": {
6
6
  "namespace": "abandonments",
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { registerReadConfig } from "./tools/readConfig.js";
6
6
  import { registerValidateConfig } from "./tools/validateConfig.js";
7
7
  import { registerLintConfig } from "./tools/lintConfig.js";
8
8
  import { registerResolveConfig } from "./tools/resolveConfig.js";
9
+ import { registerExplainConfig } from "./tools/explainConfig.js";
9
10
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
10
11
  import { registerDryRun } from "./tools/dryRun.js";
11
12
  import { registerDryRunDiff } from "./tools/dryRunDiff.js";
@@ -14,22 +15,37 @@ import { registerGetVersion } from "./tools/getVersion.js";
14
15
  import { registerPresetResources } from "./resources/presets.js";
15
16
  import { checkSetup, startupBanner } from "./lib/setupCheck.js";
16
17
  import { SERVER_VERSION } from "./lib/version.js";
18
+ import { logError } from "./lib/log.js";
19
+ // Register before any work: stdout carries JSON-RPC frames, so a stray Node-default
20
+ // log of an unhandled rejection / uncaught exception would corrupt MCP framing.
21
+ process.on("unhandledRejection", (reason) => {
22
+ logError("unhandled rejection", reason);
23
+ });
24
+ process.on("uncaughtException", (err) => {
25
+ logError("uncaught exception", err);
26
+ process.exit(1);
27
+ });
17
28
  const BASE_INSTRUCTIONS = [
18
29
  "Design and debug Renovate configurations interactively.",
19
30
  "",
20
31
  "Workflow:",
21
32
  " 1. read_config — inspect the current config in a repo",
22
33
  " 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
23
- " 3. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
24
- " 4. validate_config — check a proposed config against Renovate's schema",
25
- " 5. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
26
- " 6. dry_run — preview what Renovate would actually do (no PRs)",
27
- " 7. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
28
- " 8. write_config — save the agreed-upon config (validates first)",
34
+ " 3. explain_config — inverse of resolve_config: trace which preset set each field (offline)",
35
+ " 4. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
36
+ " 5. validate_config — check a proposed config against Renovate's schema",
37
+ " 6. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
38
+ " 7. dry_run — preview what Renovate would actually do (no PRs)",
39
+ " 8. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
40
+ " 9. write_config — save the agreed-upon config (validates first)",
29
41
  "",
30
42
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
31
43
  "Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
32
44
  ].join("\n");
45
+ if (process.platform === "win32") {
46
+ process.stderr.write("renovate-mcp does not support Windows. Supported platforms are Linux and macOS — see the `os` field in package.json. Run on WSL2 or a Linux/macOS host instead.\n");
47
+ process.exit(1);
48
+ }
33
49
  const setup = await checkSetup();
34
50
  // `RENOVATE_MCP_REQUIRE_CLI=false` is the opt-out for users who have
35
51
  // consciously chosen the offline subset and don't want the startup notice.
@@ -41,6 +57,7 @@ const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION },
41
57
  registerCheckSetup(server);
42
58
  registerReadConfig(server);
43
59
  registerResolveConfig(server);
60
+ registerExplainConfig(server);
44
61
  registerPreviewCustomManager(server);
45
62
  registerValidateConfig(server);
46
63
  registerLintConfig(server);
@@ -0,0 +1,64 @@
1
+ import { type PresetWarning, type ResolveOptions, type UnresolvedPreset } from "./presetResolver.js";
2
+ /**
3
+ * Sentinel used as the `source` of contributions that come from the user's own
4
+ * input config (i.e. the keys siblings of `extends`, not from any preset).
5
+ * Distinguishable from any real preset name because angle brackets cannot
6
+ * appear in a Renovate preset reference.
7
+ */
8
+ export declare const OWN_SOURCE = "<own>";
9
+ export interface Contribution {
10
+ /**
11
+ * Where the value came from. `<own>` for the user's input config; otherwise
12
+ * the preset reference exactly as it appeared in `extends` (e.g.
13
+ * `config:recommended`, `:automergeAll`, `github>acme/renovate-config`).
14
+ */
15
+ source: string;
16
+ /**
17
+ * Path of presets that brought this contribution in, outermost first. Empty
18
+ * when the value comes from `<own>` or from a top-level entry in the user's
19
+ * `extends`. For nested presets, lists every parent preset between the
20
+ * user's root and `source`.
21
+ */
22
+ via: string[];
23
+ /**
24
+ * The exact value `source` contributed at the point of the merge — for
25
+ * scalars this is the value `source` set, before any later preset/own
26
+ * override; for arrays this is just the slice `source` added.
27
+ */
28
+ value: unknown;
29
+ }
30
+ export type AnnotatedNode = AnnotatedLeaf | AnnotatedObject;
31
+ export interface AnnotatedLeaf {
32
+ /**
33
+ * Final merged value of this field. Last `setBy` entry is the winner for
34
+ * scalars; for arrays, every contribution's `value` is concatenated into
35
+ * `value`.
36
+ */
37
+ value: unknown;
38
+ /**
39
+ * Contributions in merge order. The last entry is the winner for scalars;
40
+ * for arrays each entry adds its own slice to `value`.
41
+ */
42
+ setBy: Contribution[];
43
+ }
44
+ export type AnnotatedObject = {
45
+ [key: string]: AnnotatedNode;
46
+ };
47
+ export interface ExplainResult {
48
+ explanation: AnnotatedObject;
49
+ presetsResolved: string[];
50
+ presetsUnresolved: UnresolvedPreset[];
51
+ warnings: PresetWarning[];
52
+ }
53
+ /**
54
+ * Inverse of `resolveConfig`: walk the same preset tree but, instead of
55
+ * collapsing everything into a flat resolved object, annotate each leaf field
56
+ * with the chain of presets that touched it. The last entry in `setBy` is the
57
+ * winner; `value` is what `resolveConfig` would have returned.
58
+ *
59
+ * Sharing as much as possible with `presetResolver`'s expansion logic
60
+ * (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so
61
+ * the two tools stay consistent — if `resolve_config` resolves a preset,
62
+ * `explain_config` does too, by definition.
63
+ */
64
+ export declare function explainConfig(config: Record<string, unknown>, options?: ResolveOptions): Promise<ExplainResult>;
@@ -0,0 +1,132 @@
1
+ import { applyArgs, loadPresetBody, parsePreset, recordTemplateWarnings, } from "./presetResolver.js";
2
+ /**
3
+ * Sentinel used as the `source` of contributions that come from the user's own
4
+ * input config (i.e. the keys siblings of `extends`, not from any preset).
5
+ * Distinguishable from any real preset name because angle brackets cannot
6
+ * appear in a Renovate preset reference.
7
+ */
8
+ export const OWN_SOURCE = "<own>";
9
+ /**
10
+ * Inverse of `resolveConfig`: walk the same preset tree but, instead of
11
+ * collapsing everything into a flat resolved object, annotate each leaf field
12
+ * with the chain of presets that touched it. The last entry in `setBy` is the
13
+ * winner; `value` is what `resolveConfig` would have returned.
14
+ *
15
+ * Sharing as much as possible with `presetResolver`'s expansion logic
16
+ * (`parsePreset`, `loadPresetBody`, `applyArgs`, `recordTemplateWarnings`) so
17
+ * the two tools stay consistent — if `resolve_config` resolves a preset,
18
+ * `explain_config` does too, by definition.
19
+ */
20
+ export async function explainConfig(config, options = {}) {
21
+ const presetsResolved = [];
22
+ const presetsUnresolved = [];
23
+ const warnings = [];
24
+ const stack = [];
25
+ const ctx = {
26
+ fetchExternal: options.fetchExternal ?? false,
27
+ timeoutMs: options.timeoutMs,
28
+ endpoint: options.endpoint,
29
+ platform: options.platform,
30
+ cache: new Map(),
31
+ };
32
+ const annotated = await expandAnnotated(config, null, [], presetsResolved, presetsUnresolved, warnings, stack, ctx);
33
+ // The user's input is always a JSON object, so the root annotation is always
34
+ // an object, not a leaf. The cast is safe; falling back to `{}` is just
35
+ // defence in depth.
36
+ const explanation = isAnnotatedLeaf(annotated) ? {} : annotated;
37
+ return { explanation, presetsResolved, presetsUnresolved, warnings };
38
+ }
39
+ async function expandAnnotated(input, ownerName, viaChain, resolvedList, unresolvedList, warningsList, stack, ctx) {
40
+ let accumulated = {};
41
+ const rawExtends = input.extends;
42
+ if (Array.isArray(rawExtends) && rawExtends.length > 0) {
43
+ // Children of `input` are reached by going through `ownerName` (if any).
44
+ // The user's root has no name; for it `viaChain` and `childVia` are equal.
45
+ const childVia = ownerName === null ? viaChain : [...viaChain, ownerName];
46
+ for (const entry of rawExtends) {
47
+ if (typeof entry !== "string") {
48
+ unresolvedList.push({
49
+ preset: String(entry),
50
+ reason: "Preset entry must be a string.",
51
+ });
52
+ continue;
53
+ }
54
+ const parsed = parsePreset(entry);
55
+ if (stack.includes(parsed.key)) {
56
+ unresolvedList.push({
57
+ preset: entry,
58
+ reason: `Cycle detected: ${[...stack, parsed.key].join(" → ")}`,
59
+ });
60
+ continue;
61
+ }
62
+ const body = await loadPresetBody(parsed, ctx, unresolvedList);
63
+ if (!body)
64
+ continue;
65
+ const { value, missingArgs, unknownTemplates } = applyArgs(body, parsed.args);
66
+ recordTemplateWarnings(entry, parsed.args.length, missingArgs, unknownTemplates, warningsList);
67
+ stack.push(parsed.key);
68
+ const subNode = await expandAnnotated(value, entry, childVia, resolvedList, unresolvedList, warningsList, stack, ctx);
69
+ stack.pop();
70
+ accumulated = mergeAnnotated(accumulated, subNode);
71
+ resolvedList.push(entry);
72
+ }
73
+ }
74
+ const { extends: _drop, ...ownKeys } = input;
75
+ const source = ownerName ?? OWN_SOURCE;
76
+ const ownAnnotated = annotateValue(ownKeys, source, viaChain);
77
+ return mergeAnnotated(accumulated, ownAnnotated);
78
+ }
79
+ function annotateValue(value, source, via) {
80
+ if (isPlainObject(value)) {
81
+ const out = {};
82
+ for (const [k, v] of Object.entries(value)) {
83
+ out[k] = annotateValue(v, source, via);
84
+ }
85
+ return out;
86
+ }
87
+ return { value, setBy: [{ source, via: [...via], value }] };
88
+ }
89
+ /**
90
+ * Mirrors `mergeConfig` in `presetResolver.ts`: arrays concat, plain objects
91
+ * recurse, everything else `b` wins. The annotated layer additionally keeps
92
+ * each contribution in `setBy` so callers can see the full chain that produced
93
+ * the final value.
94
+ */
95
+ function mergeAnnotated(a, b) {
96
+ const aIsLeaf = isAnnotatedLeaf(a);
97
+ const bIsLeaf = isAnnotatedLeaf(b);
98
+ if (!aIsLeaf && !bIsLeaf) {
99
+ const out = { ...a };
100
+ for (const [k, bChild] of Object.entries(b)) {
101
+ const existing = out[k];
102
+ out[k] = existing ? mergeAnnotated(existing, bChild) : bChild;
103
+ }
104
+ return out;
105
+ }
106
+ if (aIsLeaf && bIsLeaf) {
107
+ if (Array.isArray(a.value) && Array.isArray(b.value)) {
108
+ return {
109
+ value: [...a.value, ...b.value],
110
+ setBy: [...a.setBy, ...b.setBy],
111
+ };
112
+ }
113
+ return {
114
+ value: b.value,
115
+ setBy: [...a.setBy, ...b.setBy],
116
+ };
117
+ }
118
+ // Mixed leaf / object — `b` wins entirely (matches `mergeConfig`'s "b wins
119
+ // unless both plain objects" rule). `a`'s contributions to the discarded
120
+ // shape are dropped, mirroring the resolved config losing them.
121
+ return b;
122
+ }
123
+ function isAnnotatedLeaf(node) {
124
+ if (typeof node !== "object" || node === null)
125
+ return false;
126
+ if (!("value" in node) || !("setBy" in node))
127
+ return false;
128
+ return Array.isArray(node.setBy);
129
+ }
130
+ function isPlainObject(v) {
131
+ return typeof v === "object" && v !== null && !Array.isArray(v);
132
+ }
@@ -27,9 +27,7 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
27
27
  const maxHitsPerFile = options.maxHitsPerFile ?? DEFAULT_MAX_HITS_PER_FILE;
28
28
  const matchTimeoutMs = options.matchTimeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
29
29
  const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
30
- if (manager.matchStringsStrategy && manager.matchStringsStrategy !== "any") {
31
- warnings.push(`matchStringsStrategy='${manager.matchStringsStrategy}' is not yet supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
32
- }
30
+ const strategy = resolveStrategy(manager.matchStringsStrategy, warnings);
33
31
  // Surface malformed user regexes eagerly, before we do any filesystem work.
34
32
  // The Worker path otherwise reports these as generic worker errors.
35
33
  for (const src of manager.fileMatch)
@@ -91,35 +89,162 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
91
89
  warnings.push(`Could not read ${rel}: ${err.message}`);
92
90
  continue;
93
91
  }
94
- let perFileHits = 0;
95
- for (let i = 0; i < manager.matchStrings.length; i++) {
96
- const source = manager.matchStrings[i];
97
- const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
98
- if (res.timedOut) {
99
- warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex (e.g. avoid nested quantifiers like (.*)*) or raise matchTimeoutMs.`);
100
- continue;
101
- }
102
- for (const m of res.matches) {
103
- if (perFileHits >= maxHitsPerFile) {
104
- warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
105
- break;
106
- }
107
- const line = lineNumberAt(content, m.index);
108
- hits.push({
109
- file: rel,
110
- matchStringIndex: i,
111
- line,
112
- match: m.match,
113
- groups: m.groups,
114
- });
115
- extractedDeps.push(buildExtractedDep(rel, line, m.groups, manager));
116
- perFileHits++;
117
- }
118
- if (perFileHits >= maxHitsPerFile)
92
+ const fileResult = await applyMatchStrings(rel, content, manager, strategy, matchTimeoutMs, maxHitsPerFile);
93
+ hits.push(...fileResult.hits);
94
+ extractedDeps.push(...fileResult.deps);
95
+ warnings.push(...fileResult.warnings);
96
+ }
97
+ return { filesWalked, filesMatched, hits, extractedDeps, warnings };
98
+ }
99
+ function resolveStrategy(raw, warnings) {
100
+ if (raw === undefined || raw === "any")
101
+ return "any";
102
+ if (raw === "combination" || raw === "recursive")
103
+ return raw;
104
+ warnings.push(`matchStringsStrategy='${raw}' is not supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
105
+ return "any";
106
+ }
107
+ async function applyMatchStrings(rel, content, manager, strategy, matchTimeoutMs, maxHitsPerFile) {
108
+ if (strategy === "combination") {
109
+ return applyCombination(rel, content, manager, matchTimeoutMs);
110
+ }
111
+ if (strategy === "recursive") {
112
+ return applyRecursive(rel, content, manager, matchTimeoutMs, maxHitsPerFile);
113
+ }
114
+ return applyAny(rel, content, manager, matchTimeoutMs, maxHitsPerFile);
115
+ }
116
+ async function applyAny(rel, content, manager, matchTimeoutMs, maxHitsPerFile) {
117
+ const hits = [];
118
+ const deps = [];
119
+ const warnings = [];
120
+ let perFileHits = 0;
121
+ for (let i = 0; i < manager.matchStrings.length; i++) {
122
+ const source = manager.matchStrings[i];
123
+ const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
124
+ if (res.timedOut) {
125
+ warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex (e.g. avoid nested quantifiers like (.*)*) or raise matchTimeoutMs.`);
126
+ continue;
127
+ }
128
+ for (const m of res.matches) {
129
+ if (perFileHits >= maxHitsPerFile) {
130
+ warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
119
131
  break;
132
+ }
133
+ const line = lineNumberAt(content, m.index);
134
+ hits.push({
135
+ file: rel,
136
+ matchStringIndex: i,
137
+ line,
138
+ match: m.match,
139
+ groups: m.groups,
140
+ });
141
+ deps.push(buildExtractedDep(rel, line, m.groups, manager));
142
+ perFileHits++;
120
143
  }
144
+ if (perFileHits >= maxHitsPerFile)
145
+ break;
146
+ }
147
+ return { hits, deps, warnings };
148
+ }
149
+ /**
150
+ * Mirrors Renovate's `handleCombination`: every matchString must hit at least
151
+ * once for the file to produce a dep; all matches' named groups are merged
152
+ * (later matches override earlier on key conflicts) into a single dep. We still
153
+ * surface every match as a `hit` so the user can see which lines contributed,
154
+ * but `extractedDeps` collapses to one entry per file.
155
+ */
156
+ async function applyCombination(rel, content, manager, matchTimeoutMs) {
157
+ const hits = [];
158
+ const warnings = [];
159
+ const perStringMatches = [];
160
+ for (let i = 0; i < manager.matchStrings.length; i++) {
161
+ const source = manager.matchStrings[i];
162
+ const res = await runMatchAllInWorker(source, "gm", content, matchTimeoutMs);
163
+ if (res.timedOut) {
164
+ warnings.push(`${rel}: matchStrings[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; combination strategy could not produce a dep for this file. Simplify the regex or raise matchTimeoutMs.`);
165
+ return { hits: [], deps: [], warnings };
166
+ }
167
+ if (res.matches.length === 0) {
168
+ // Combination requires every matchString to hit at least once.
169
+ return { hits: [], deps: [], warnings };
170
+ }
171
+ perStringMatches.push(res.matches);
172
+ for (const m of res.matches) {
173
+ hits.push({
174
+ file: rel,
175
+ matchStringIndex: i,
176
+ line: lineNumberAt(content, m.index),
177
+ match: m.match,
178
+ groups: m.groups,
179
+ });
180
+ }
181
+ }
182
+ // Merge groups in match order across all matchStrings (later overrides earlier).
183
+ const mergedGroups = {};
184
+ for (const matches of perStringMatches) {
185
+ for (const m of matches)
186
+ Object.assign(mergedGroups, m.groups);
187
+ }
188
+ // Anchor the dep at the first match's line so the user can navigate to the file.
189
+ const firstHit = hits[0];
190
+ const deps = [buildExtractedDep(rel, firstHit.line, mergedGroups, manager)];
191
+ return { hits, deps, warnings };
192
+ }
193
+ /**
194
+ * Mirrors Renovate's `handleRecursive`: matchStrings[0] runs against the file,
195
+ * each match's text becomes the input for matchStrings[1], and so on. At the
196
+ * leaf level (after the last matchString), the merged groups from every level
197
+ * become a dep. Outer-level matches contribute groups but are not themselves
198
+ * deps; only the leaves are. `hits` reports the leaf-level matches with the
199
+ * merged groups, which matches the `any` strategy's "one hit per dep" shape.
200
+ */
201
+ async function applyRecursive(rel, content, manager, matchTimeoutMs, maxHitsPerFile) {
202
+ const state = {
203
+ rel,
204
+ manager,
205
+ originalContent: content,
206
+ matchTimeoutMs,
207
+ maxHitsPerFile,
208
+ hits: [],
209
+ deps: [],
210
+ warnings: [],
211
+ capped: false,
212
+ };
213
+ await recurse(state, content, 0, 0, {});
214
+ return { hits: state.hits, deps: state.deps, warnings: state.warnings };
215
+ }
216
+ async function recurse(state, content, baseOffset, index, combinedGroups) {
217
+ if (state.capped)
218
+ return;
219
+ const { manager, rel } = state;
220
+ if (index === manager.matchStrings.length) {
221
+ if (state.deps.length >= state.maxHitsPerFile) {
222
+ state.warnings.push(`${rel}: capped at ${state.maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
223
+ state.capped = true;
224
+ return;
225
+ }
226
+ const line = lineNumberAt(state.originalContent, baseOffset);
227
+ state.hits.push({
228
+ file: rel,
229
+ matchStringIndex: manager.matchStrings.length - 1,
230
+ line,
231
+ match: content,
232
+ groups: combinedGroups,
233
+ });
234
+ state.deps.push(buildExtractedDep(rel, line, combinedGroups, manager));
235
+ return;
236
+ }
237
+ const source = manager.matchStrings[index];
238
+ const res = await runMatchAllInWorker(source, "gm", content, state.matchTimeoutMs);
239
+ if (res.timedOut) {
240
+ state.warnings.push(`${rel}: matchStrings[${index}] /${source}/ exceeded ${state.matchTimeoutMs}ms and was aborted; any matches in this file for this pattern were skipped. Simplify the regex or raise matchTimeoutMs.`);
241
+ return;
242
+ }
243
+ for (const m of res.matches) {
244
+ if (state.capped)
245
+ return;
246
+ await recurse(state, m.match, baseOffset + m.index, index + 1, { ...combinedGroups, ...m.groups });
121
247
  }
122
- return { filesWalked, filesMatched, hits, extractedDeps, warnings };
123
248
  }
124
249
  function buildExtractedDep(file, line, groups, manager) {
125
250
  const dep = { file, line };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Validate a user-provided `endpoint` URL before any network call attaches
3
+ * auth headers or before it is forwarded to the Renovate child as
4
+ * `--endpoint=`. The check is intentionally string-level (no DNS): it
5
+ * refuses non-https schemes, missing/userinfo hosts, and any RFC 1918 /
6
+ * loopback / link-local literal. The point is to make a prompt-injected
7
+ * `endpoint` value unable to coerce a token-bearing request to an
8
+ * attacker-controlled or internal-only address.
9
+ *
10
+ * Throws `EndpointValidationError` with a user-facing reason on rejection.
11
+ * Call sites map that to their own error shape (`{ ok: false, reason }`,
12
+ * `isError: true`, etc.).
13
+ */
14
+ export declare class EndpointValidationError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ export declare function validateEndpoint(endpoint: string): void;