renovate-mcp 0.3.0 → 0.5.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
@@ -1,43 +1,77 @@
1
1
  # renovate-mcp
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/renovate-mcp.svg)](https://www.npmjs.com/package/renovate-mcp)
4
+ [![CI](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/tibuntu/renovate-mcp/actions/workflows/ci.yml)
5
+ [![Node ≥ 24](https://img.shields.io/node/v/renovate-mcp.svg)](https://nodejs.org/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
7
+
3
8
  An MCP server for designing [Renovate](https://github.com/renovatebot/renovate) configurations interactively. Point it at a local repo and let an LLM help you read, validate, preview, and save `renovate.json`.
4
9
 
10
+ ## Contents
11
+
12
+ - [What it does](#what-it-does)
13
+ - [What this is NOT](#what-this-is-not)
14
+ - [Requirements](#requirements)
15
+ - [Install](#install)
16
+ - [Example prompts](#example-prompts)
17
+ - [Example session](#example-session)
18
+ - [Development](#development)
19
+ - [Release flow](#release-flow)
20
+ - [Design notes](#design-notes)
21
+
5
22
  ## What it does
6
23
 
7
- Seven tools plus a preset reference:
24
+ Eight tools plus a preset reference:
8
25
 
9
- | Tool | What it does |
26
+ | Tool | Purpose |
10
27
  | --- | --- |
11
- | `check_setup` | Report Renovate CLI + validator availability, versions, env overrides, and install hints. Also runs at server startup and embeds the result in the server's `instructions` when anything's missing. |
12
- | `read_config` | Locate and parse `renovate.json` / `renovate.json5` / `.renovaterc*` / `.github/renovate.json` / `.gitlab/renovate.json` / `package.json#renovate` in a repo — in Renovate's priority order. |
13
- | `resolve_config` | Expand every `extends` preset against the committed catalogue and return the fully resolved config (offline; no `renovate` invocation). Flags unresolvable entries with a reason. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true` (auth via `GITHUB_TOKEN` / `GITLAB_TOKEN` / `RENOVATE_TOKEN`). For GitHub Enterprise or self-hosted GitLab, pass `endpoint` (API base URL) — and `platform` in addition to route `local>` presets through the same host. `bitbucket>`, `gitea>`, and npm presets remain in `presetsUnresolved` regardless. |
14
- | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — offline, no `renovate` invocation. Shows which files match `fileMatch`, which lines match each `matchStrings` regex with named capture groups, and what dep info the template fields produce. Intended for fast regex iteration; run `dry_run` afterwards for full-fidelity confirmation. |
28
+ | `check_setup` | Report Renovate CLI + validator availability, versions, and install hints. Also runs at startup. |
29
+ | `read_config` | Locate and parse a repo's Renovate config (`renovate.json`, `renovate.json5`, `.renovaterc*`, `package.json#renovate`, …) in priority order. |
30
+ | `resolve_config` | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS with `externalPresets: true`. |
31
+ | `preview_custom_manager` | Preview a `customManagers` (regex) entry against a local repo — shows file/line hits and extracted dep info. Offline. |
15
32
  | `validate_config` | Run `renovate-config-validator` against a file or inline object. |
16
- | `dry_run` | Run Renovate with `--platform=local --dry-run`, return the structured JSON report (no PRs, no pushes). |
17
- | `write_config` | Validate, then write a config to disk (temp-file → validate → atomic rename). Refuses to save invalid configs unless `force: true`. |
18
- | `renovate://presets` (resource) | Thin markdown index of every namespace (with preset counts) covering all 1000+ built-in presets. Snapshot from the installed `renovate` devDep. |
19
- | `renovate://presets/{namespace}` (resource template) | Markdown listing of every preset in a single namespace (e.g. `renovate://presets/config`). Fetch this instead of the full index when the LLM only cares about one namespace — cuts token cost by roughly 1/N where N is the number of namespaces. |
20
- | `renovate://preset/{name}` (resource template) | Expanded JSON body (description, extends, packageRules, …) for any single built-in preset. E.g. `renovate://preset/config:recommended`. |
33
+ | `lint_config` | Semantic lint for Renovate-specific footguns schema validation misses (mostly malformed `/…/` regex patterns). Offline. |
34
+ | `dry_run` | Run Renovate with `--platform=local --dry-run` and return the structured JSON report. No PRs, no pushes. Emits MCP progress notifications during the run when the caller supplies a `progressToken`. |
35
+ | `write_config` | Validate, then atomically write a config to disk. Refuses to save invalid configs unless `force: true`. |
36
+ | `renovate://presets` (resource) | Markdown index of all 1000+ built-in presets grouped by namespace. |
37
+ | `renovate://presets/{namespace}` (resource template) | Markdown listing for a single namespace (e.g. `renovate://presets/config`) — cheaper than the full index. |
38
+ | `renovate://preset/{name}` (resource template) | Expanded JSON body for one preset (e.g. `renovate://preset/config:recommended`). |
39
+
40
+ See [Design notes](#design-notes) for implementation details (timeouts, safety caps, auth scrubbing, merge semantics).
41
+
42
+ ## What this is NOT
43
+
44
+ - **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.
45
+ - **`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`.
46
+ - **`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`.
47
+ - **`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.
21
48
 
22
49
  ## Requirements
23
50
 
51
+ **Required**
52
+
24
53
  - Node.js ≥ 24 (aligns with Renovate's own engine requirement).
25
- - Renovate available on your `PATH` — either a global install (`npm i -g renovate`) or a project-local install that exposes `renovate` and `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`) work without it.
26
- - Override binary locations with env vars if needed: `RENOVATE_BIN`, `RENOVATE_CONFIG_VALIDATOR_BIN`.
27
- - Optional for `resolve_config` with `externalPresets: true`: `GITHUB_TOKEN` / `GITLAB_TOKEN` (or `RENOVATE_TOKEN` as a fallback) for fetching presets from private repos or to avoid rate limits. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` if you also want `local>` presets routed there); `RENOVATE_ENDPOINT` is **not** read. Any env vars the tool does read must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell, since the MCP server runs as a child of Claude and does not inherit shell env.
54
+ - 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.
55
+
56
+ **Optional**
57
+
58
+ - `RENOVATE_BIN` / `RENOVATE_CONFIG_VALIDATOR_BIN` — override binary locations.
59
+ - `RENOVATE_MCP_REQUIRE_CLI=false` — suppress the startup "partial availability" notice when you only intend to use the offline tools.
60
+ - `RENOVATE_TOKEN` (preferred) or `GITHUB_TOKEN` / `GITLAB_TOKEN` as platform-specific fallbacks — only needed by `resolve_config` with `externalPresets: true` for fetching presets from private repos or avoiding rate limits. `RENOVATE_TOKEN` wins when both are set. For GitHub Enterprise / self-hosted GitLab, pass the `endpoint` tool input (and `platform` to route `local>` presets through the same host); `RENOVATE_ENDPOINT` is **not** read.
61
+ - Private-registry credentials for `dry_run` — whatever Renovate itself would need at lookup time (`COMPOSER_AUTH`, `NPM_TOKEN` / `.npmrc`, Docker registry creds, or a `RENOVATE_HOST_RULES` JSON blob). Alternatively encode these as `hostRules` in the Renovate config, or pass them per-call via the `hostRules` input on `dry_run` (no MCP restart needed). Per-call `hostRules` are appended to whatever the repo's own config declares. Values reach Renovate as JSON through the tool-call transport, so the calling LLM sees them in its context — prefer the env route if that matters. Without any of these, Renovate's lookup often returns 0 updates silently; `dry_run` scans its logs for auth failures and surfaces them under `problems`.
62
+
63
+ > **Note:** all env vars (tokens, `COMPOSER_AUTH`, `RENOVATE_HOST_RULES`, binary overrides, …) must be set on the MCP server process itself — via the `env` key in `claude_desktop_config.json` / `.mcp.json`, not your shell — since the MCP server runs as a child of the client and does not inherit shell env.
28
64
 
29
65
  ## Install
30
66
 
31
- Once published to npm, no install is needed — `npx` will fetch it on demand. For local development, clone and build:
67
+ `npx` fetches the [published package](https://www.npmjs.com/package/renovate-mcp) on demand — no manual install needed. For local development, clone and build first:
32
68
 
33
69
  ```bash
34
70
  npm install
35
71
  npm run build
36
72
  ```
37
73
 
38
- ## Use with Claude Code
39
-
40
- Add to `.mcp.json` (project) or `~/.claude.json` (user):
74
+ Add the following `mcpServers` entry to your client's config file:
41
75
 
42
76
  ```json
43
77
  {
@@ -50,33 +84,27 @@ Add to `.mcp.json` (project) or `~/.claude.json` (user):
50
84
  }
51
85
  ```
52
86
 
53
- For local development, swap to:
87
+ For local development, swap to `"command": "node"` with `"args": ["/absolute/path/to/renovate-mcp/dist/index.js"]`.
54
88
 
55
- ```json
56
- {
57
- "mcpServers": {
58
- "renovate": {
59
- "command": "node",
60
- "args": ["/absolute/path/to/renovate-mcp/dist/index.js"]
61
- }
62
- }
63
- }
64
- ```
89
+ ### Claude Code
65
90
 
66
- ## Use with Claude Desktop
91
+ Config lives in `.mcp.json` (project-scoped) or `~/.claude.json` (user-scoped).
67
92
 
68
- Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
93
+ ### Claude Desktop
69
94
 
70
- ```json
71
- {
72
- "mcpServers": {
73
- "renovate": {
74
- "command": "npx",
75
- "args": ["-y", "renovate-mcp"]
76
- }
77
- }
78
- }
79
- ```
95
+ Config lives in `~/Library/Application Support/Claude/claude_desktop_config.json`.
96
+
97
+ ### Other MCP clients
98
+
99
+ Any client that can launch a stdio MCP server works — point it at the same command shown above.
100
+
101
+ ### Verify the wiring
102
+
103
+ Restart your client and prompt it:
104
+
105
+ > List the namespaces available under `renovate://presets`.
106
+
107
+ A response listing namespaces like `config`, `docker`, `npm`, `helpers`, … confirms the server is reachable and the resource is exposed. If the client reports the tool or resource as unavailable, re-check the config file path and command.
80
108
 
81
109
  ## Example prompts
82
110
 
@@ -111,6 +139,32 @@ Once the server is wired up, try prompts like these. They're written for Claude
111
139
  - "Do a dry run and show me which PRs Renovate would open — no pushes."
112
140
  - "Add `:semanticCommits` to my `extends`, validate it, and save back to `renovate.json`."
113
141
 
142
+ ## Example session
143
+
144
+ A transcript-style walkthrough: design a Dockerfile custom manager from scratch, validate it, dry-run, and save. Turns are abbreviated — your client will show the actual tool-call JSON.
145
+
146
+ > **You:** I've got `# renovate: datasource=docker depName=<image>` comments above `FROM` lines in my Dockerfiles. Draft a `customManagers` entry and preview it against this repo.
147
+ >
148
+ > **Claude** calls `preview_custom_manager` with a first-draft `fileMatch` + `matchStrings`.
149
+ > → 4 Dockerfiles matched `fileMatch`, 0 lines matched `matchStrings`. The regex anchored on `ARG`, but the Dockerfiles use `FROM`.
150
+ >
151
+ > **You:** Rewrite `matchStrings` to anchor on the renovate comment, then `FROM <image>:<version>` on the next line.
152
+ >
153
+ > **Claude** calls `preview_custom_manager` again with the fixed regex.
154
+ > → 4 files, 4 line hits. Extracted: `postgres:15.3-alpine`, `redis:7.2`, `nginx:1.25`, `node:20.11`. Named groups `depName` and `currentValue` populated on every hit.
155
+ >
156
+ > **You:** Good. Validate the full config inline, with this `customManagers` entry alongside my existing `extends`.
157
+ >
158
+ > **Claude** calls `validate_config` with the inline config. → valid.
159
+ >
160
+ > **You:** Now dry-run so I can see what Renovate would actually open.
161
+ >
162
+ > **Claude** calls `dry_run`. → 2 updates: `postgres` 15.3-alpine → 15.5-alpine, `redis` 7.2 → 7.4. No entries in `problems`.
163
+ >
164
+ > **You:** Save it.
165
+ >
166
+ > **Claude** calls `write_config` on `renovate.json`. → validated, written atomically.
167
+
114
168
  ## Development
115
169
 
116
170
  ```bash
@@ -120,12 +174,13 @@ npm run build # compile to dist/
120
174
  npm start # run the built server over stdio
121
175
  npm test # vitest run (builds first)
122
176
  npm run test:watch # vitest watch mode
177
+ npm run test:coverage # vitest run --coverage (writes coverage/ report)
123
178
  npm run generate:presets # regenerate src/data/presets.generated.ts from the renovate devDep
124
179
  ```
125
180
 
126
181
  The preset catalogue at `src/data/presets.generated.ts` is a committed snapshot of Renovate's built-in presets. Runtime code never imports the `renovate` package — only `scripts/generate-presets.mjs` does. Regenerate after bumping the `renovate` devDependency.
127
182
 
128
- CI runs `typecheck`, `build`, and `test` on Node 24 for every PR and push to `main` (see `.github/workflows/ci.yml`).
183
+ 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.
129
184
 
130
185
  ## Release flow
131
186
 
@@ -142,9 +197,18 @@ Secrets required on the repo: `RELEASE_PLEASE_TOKEN` (a PAT for release-please).
142
197
 
143
198
  ## Design notes
144
199
 
200
+ Scope and non-goals are summarized in [What this is NOT](#what-this-is-not); this section covers implementation decisions behind the scope that *is* supported.
201
+
145
202
  - `validate_config`, `dry_run`, and `write_config` shell out to the Renovate CLI rather than importing Renovate as a library — this decouples our Node version from Renovate's (currently Node 24).
146
- - `resolve_config` and `preview_custom_manager` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
147
- - `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesScanned` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
203
+ - `resolve_config`, `preview_custom_manager`, and `lint_config` are fully in-process and never invoke the Renovate CLI, so they work without a Renovate install.
204
+ - `lint_config` is a semantic lint pass that sits alongside `validate_config` rather than replacing it: schema validation catches structural bugs, the linter catches Renovate-specific footguns that schema validation declares valid — most commonly a pattern like `"matchPackageNames": ["/devops\\/pipelines\\/.+"]` where a trailing `/` is missing and Renovate silently degrades the value to an exact-string match that never hits. The ruleset is intentionally small (two rules: `dead-regex-missing-slash`, `unwrapped-regex`), scoped to the four regex-aware fields named in the proposal, and tuned to avoid false positives on benign exact strings containing a `.`. Rule IDs are stable so findings can be suppressed by callers.
205
+ - `preview_custom_manager` honors `.gitignore` (including nested `.gitignore`s and `.git/info/exclude`) when walking the repo, so generated/vendored directories like `dist/`, `.next/`, `target/`, `__pycache__/` don't crowd out real hits against the `maxFilesWalked` cap. `node_modules/` and `.git/` are always skipped as a safety net even when no `.gitignore` is present.
206
+ - `preview_custom_manager` exposes two separate safety caps so the warning text can name which one tripped: `maxFilesWalked` (default 2000) bounds the directory walk before any `fileMatch` testing, and `maxFilesMatched` (default 500) bounds the result set after `fileMatch` is applied. Previously a single `maxFilesScanned` conflated the two, leaving the user unable to tell whether to narrow `fileMatch` or widen the walk.
207
+ - `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.
208
+ - `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.
148
209
  - `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.
149
- - `dry_run` uses `--report-type=file` so we get a structured JSON report instead of scraping stdout.
210
+ - `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.
211
+ - 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.
212
+ - `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 `--config-file=`, and deleted in a `finally` block. Token/password values are scrubbed from both the detected `problems` list and the `logTail` fallback before returning.
213
+ - `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.
150
214
  - `write_config` writes to a temp file, validates, then atomically renames — so a failed validation never leaves a broken config on disk.
package/dist/index.js CHANGED
@@ -4,12 +4,13 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { registerCheckSetup } from "./tools/checkSetup.js";
5
5
  import { registerReadConfig } from "./tools/readConfig.js";
6
6
  import { registerValidateConfig } from "./tools/validateConfig.js";
7
+ import { registerLintConfig } from "./tools/lintConfig.js";
7
8
  import { registerResolveConfig } from "./tools/resolveConfig.js";
8
9
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
9
10
  import { registerDryRun } from "./tools/dryRun.js";
10
11
  import { registerWriteConfig } from "./tools/writeConfig.js";
11
12
  import { registerPresetResources } from "./resources/presets.js";
12
- import { checkSetup, describeSetup } from "./lib/setupCheck.js";
13
+ import { checkSetup, startupBanner } from "./lib/setupCheck.js";
13
14
  const BASE_INSTRUCTIONS = [
14
15
  "Design and debug Renovate configurations interactively.",
15
16
  "",
@@ -18,23 +19,28 @@ const BASE_INSTRUCTIONS = [
18
19
  " 2. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
19
20
  " 3. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
20
21
  " 4. validate_config — check a proposed config against Renovate's schema",
21
- " 5. dry_run — preview what Renovate would actually do (no PRs)",
22
- " 6. write_config — save the agreed-upon config (validates first)",
22
+ " 5. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
23
+ " 6. dry_run — preview what Renovate would actually do (no PRs)",
24
+ " 7. write_config — save the agreed-upon config (validates first)",
23
25
  "",
24
26
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
25
27
  "Built-in preset reference: renovate://presets (namespace index), renovate://presets/{namespace} (one namespace), renovate://preset/{name} (one preset's expanded JSON).",
26
28
  ].join("\n");
27
29
  const setup = await checkSetup();
28
- const instructions = setup.ok
29
- ? BASE_INSTRUCTIONS
30
- : [BASE_INSTRUCTIONS, "", "Startup setup check:", describeSetup(setup)].join("\n");
31
- const SERVER_VERSION = "0.3.0"; // x-release-please-version
30
+ // `RENOVATE_MCP_REQUIRE_CLI=false` is the opt-out for users who have
31
+ // consciously chosen the offline subset and don't want the startup notice.
32
+ const requireCliRaw = process.env.RENOVATE_MCP_REQUIRE_CLI?.trim().toLowerCase();
33
+ const suppressBanner = requireCliRaw === "false" || requireCliRaw === "0";
34
+ const banner = suppressBanner ? null : startupBanner(setup);
35
+ const instructions = banner ? [BASE_INSTRUCTIONS, "", banner].join("\n") : BASE_INSTRUCTIONS;
36
+ const SERVER_VERSION = "0.5.0"; // x-release-please-version
32
37
  const server = new McpServer({ name: "renovate-mcp", version: SERVER_VERSION }, { instructions });
33
38
  registerCheckSetup(server);
34
39
  registerReadConfig(server);
35
40
  registerResolveConfig(server);
36
41
  registerPreviewCustomManager(server);
37
42
  registerValidateConfig(server);
43
+ registerLintConfig(server);
38
44
  registerDryRun(server);
39
45
  registerWriteConfig(server);
40
46
  registerPresetResources(server);
@@ -0,0 +1,8 @@
1
+ export type LintRuleId = "dead-regex-missing-slash" | "unwrapped-regex";
2
+ export interface LintFinding {
3
+ ruleId: LintRuleId;
4
+ path: string;
5
+ value: string;
6
+ message: string;
7
+ }
8
+ export declare function lintConfig(config: unknown): LintFinding[];
@@ -0,0 +1,74 @@
1
+ const REGEX_AWARE_FIELDS = new Set([
2
+ "matchPackageNames",
3
+ "matchDepNames",
4
+ "matchSourceUrls",
5
+ "matchCurrentVersion",
6
+ ]);
7
+ export function lintConfig(config) {
8
+ const findings = [];
9
+ walk(config, "", findings);
10
+ return findings;
11
+ }
12
+ function walk(node, pathStr, findings) {
13
+ if (Array.isArray(node)) {
14
+ node.forEach((item, i) => walk(item, `${pathStr}[${i}]`, findings));
15
+ return;
16
+ }
17
+ if (node && typeof node === "object") {
18
+ for (const [key, value] of Object.entries(node)) {
19
+ const childPath = pathStr ? `${pathStr}.${key}` : key;
20
+ if (REGEX_AWARE_FIELDS.has(key)) {
21
+ if (Array.isArray(value)) {
22
+ value.forEach((entry, i) => {
23
+ if (typeof entry === "string") {
24
+ checkPattern(entry, `${childPath}[${i}]`, findings);
25
+ }
26
+ });
27
+ }
28
+ else if (typeof value === "string") {
29
+ checkPattern(value, childPath, findings);
30
+ }
31
+ }
32
+ else {
33
+ walk(value, childPath, findings);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ function checkPattern(raw, path, findings) {
39
+ // `!` is Renovate's exclusion prefix; strip it before checking regex wrapping.
40
+ const stripped = raw.startsWith("!") ? raw.slice(1) : raw;
41
+ // Too short to meaningfully be a /…/ regex or trigger a metachar rule.
42
+ if (stripped.length < 2)
43
+ return;
44
+ const startsSlash = stripped.startsWith("/");
45
+ const endsSlash = stripped.endsWith("/");
46
+ if (startsSlash !== endsSlash) {
47
+ findings.push({
48
+ ruleId: "dead-regex-missing-slash",
49
+ path,
50
+ value: raw,
51
+ message: startsSlash
52
+ ? "Value starts with '/' but does not end with '/'. Renovate will treat this as an exact-match string, not a regex. Add the trailing '/' if a regex match was intended, otherwise remove the leading '/'."
53
+ : "Value ends with '/' but does not start with '/'. Renovate will treat this as an exact-match string, not a regex. Add the leading '/' if a regex match was intended, otherwise remove the trailing '/'.",
54
+ });
55
+ return;
56
+ }
57
+ if (startsSlash && endsSlash)
58
+ return;
59
+ if (hasStrongRegexSignal(stripped)) {
60
+ findings.push({
61
+ ruleId: "unwrapped-regex",
62
+ path,
63
+ value: raw,
64
+ message: `Value contains regex metacharacters but is not wrapped in '/…/'. Renovate will treat it as an exact-match string. Wrap it as '/${stripped}/' (or '!/${stripped}/' to negate) if a regex match was intended.`,
65
+ });
66
+ }
67
+ }
68
+ function hasStrongRegexSignal(s) {
69
+ return (/\\[dwsbDWSB]/.test(s) ||
70
+ /\.[+*?]/.test(s) ||
71
+ /\\\./.test(s) ||
72
+ /\(\?[:!=<]/.test(s) ||
73
+ /\[[^\]]+\]/.test(s));
74
+ }
@@ -36,11 +36,14 @@ export interface ExtractedDep {
36
36
  autoReplaceString?: string;
37
37
  }
38
38
  export interface PreviewOptions {
39
- maxFilesScanned?: number;
39
+ maxFilesWalked?: number;
40
+ maxFilesMatched?: number;
40
41
  maxHitsPerFile?: number;
42
+ matchTimeoutMs?: number;
43
+ maxFileBytes?: number;
41
44
  }
42
45
  export interface PreviewResult {
43
- filesScanned: number;
46
+ filesWalked: number;
44
47
  filesMatched: string[];
45
48
  hits: PreviewHit[];
46
49
  extractedDeps: ExtractedDep[];
@@ -1,8 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
+ import { Worker } from "node:worker_threads";
3
4
  import ignore from "ignore";
4
- const DEFAULT_MAX_FILES_SCANNED = 2000;
5
+ const DEFAULT_MAX_FILES_WALKED = 2000;
6
+ const DEFAULT_MAX_FILES_MATCHED = 500;
5
7
  const DEFAULT_MAX_HITS_PER_FILE = 100;
8
+ const DEFAULT_MATCH_TIMEOUT_MS = 2000;
9
+ const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
6
10
  const SKIP_DIRS = new Set([".git", "node_modules"]);
7
11
  const TEMPLATE_FIELD_MAP = [
8
12
  ["depNameTemplate", "depName"],
@@ -18,29 +22,67 @@ const TEMPLATE_FIELD_MAP = [
18
22
  ];
19
23
  export async function previewCustomManager(repoPath, manager, options = {}) {
20
24
  const warnings = [];
21
- const maxFilesScanned = options.maxFilesScanned ?? DEFAULT_MAX_FILES_SCANNED;
25
+ const maxFilesWalked = options.maxFilesWalked ?? DEFAULT_MAX_FILES_WALKED;
26
+ const maxFilesMatched = options.maxFilesMatched ?? DEFAULT_MAX_FILES_MATCHED;
22
27
  const maxHitsPerFile = options.maxHitsPerFile ?? DEFAULT_MAX_HITS_PER_FILE;
28
+ const matchTimeoutMs = options.matchTimeoutMs ?? DEFAULT_MATCH_TIMEOUT_MS;
29
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
23
30
  if (manager.matchStringsStrategy && manager.matchStringsStrategy !== "any") {
24
31
  warnings.push(`matchStringsStrategy='${manager.matchStringsStrategy}' is not yet supported by this preview tool; treating as 'any'. Run dry_run for full-fidelity behavior.`);
25
32
  }
26
- const fileMatchRes = manager.fileMatch.map((s) => compileRegex(s));
27
- const matchStringRes = manager.matchStrings.map((s) => compileRegex(s, "gm"));
28
- const filesMatched = [];
29
- let filesScanned = 0;
33
+ // Surface malformed user regexes eagerly, before we do any filesystem work.
34
+ // The Worker path otherwise reports these as generic worker errors.
35
+ for (const src of manager.fileMatch)
36
+ validateRegex(src);
37
+ for (const src of manager.matchStrings)
38
+ validateRegex(src);
39
+ // Walk first, then run fileMatch regexes in a worker so a pathological
40
+ // pattern can't pin the event loop on the path-testing phase.
41
+ const allPaths = [];
30
42
  for await (const rel of walk(repoPath)) {
31
- if (filesScanned >= maxFilesScanned) {
32
- warnings.push(`Stopped after scanning ${maxFilesScanned} files. Increase maxFilesScanned to widen the search.`);
43
+ if (allPaths.length >= maxFilesWalked) {
44
+ warnings.push(`Stopped walking the repo after ${maxFilesWalked} files; remaining files were never tested against fileMatch. Add ignores (or a .gitignore) to prune irrelevant directories, or raise maxFilesWalked.`);
33
45
  break;
34
46
  }
35
- filesScanned++;
36
- if (fileMatchRes.some((re) => re.test(rel))) {
37
- filesMatched.push(rel);
47
+ allPaths.push(rel);
48
+ }
49
+ const filesWalked = allPaths.length;
50
+ const matchedSet = new Set();
51
+ for (let i = 0; i < manager.fileMatch.length; i++) {
52
+ const source = manager.fileMatch[i];
53
+ const res = await runTestInWorker(source, "", allPaths, matchTimeoutMs);
54
+ if (res.timedOut) {
55
+ warnings.push(`fileMatch[${i}] /${source}/ exceeded ${matchTimeoutMs}ms and was aborted; no paths were matched against this pattern. Simplify the regex (e.g. avoid nested quantifiers like (a+)+) or raise matchTimeoutMs.`);
56
+ continue;
38
57
  }
58
+ for (const p of res.paths)
59
+ matchedSet.add(p);
60
+ }
61
+ // Preserve walk order so output is stable.
62
+ const allFilesMatched = allPaths.filter((p) => matchedSet.has(p));
63
+ // Distinct from the walk cap: this caps the *result set*. A broad fileMatch
64
+ // regex over a large repo can produce thousands of hits; truncate with a
65
+ // dedicated warning so the user can tell which cap tripped.
66
+ const filesMatched = allFilesMatched.slice(0, maxFilesMatched);
67
+ if (allFilesMatched.length > maxFilesMatched) {
68
+ warnings.push(`fileMatch matched ${allFilesMatched.length} files; capped result set at maxFilesMatched=${maxFilesMatched}. Narrow fileMatch to target the intended paths, or raise maxFilesMatched.`);
39
69
  }
40
70
  const hits = [];
41
71
  const extractedDeps = [];
42
72
  for (const rel of filesMatched) {
43
73
  const abs = path.join(repoPath, rel);
74
+ let stat;
75
+ try {
76
+ stat = await fs.stat(abs);
77
+ }
78
+ catch (err) {
79
+ warnings.push(`Could not stat ${rel}: ${err.message}`);
80
+ continue;
81
+ }
82
+ if (stat.size > maxFileBytes) {
83
+ warnings.push(`${rel}: skipped, ${stat.size} bytes exceeds maxFileBytes=${maxFileBytes}. Tighten fileMatch to exclude it, or raise maxFileBytes.`);
84
+ continue;
85
+ }
44
86
  let content;
45
87
  try {
46
88
  content = await fs.readFile(abs, "utf8");
@@ -50,28 +92,34 @@ export async function previewCustomManager(repoPath, manager, options = {}) {
50
92
  continue;
51
93
  }
52
94
  let perFileHits = 0;
53
- for (let i = 0; i < matchStringRes.length; i++) {
54
- const re = matchStringRes[i];
55
- // RegExp objects with the `g` flag are stateful — reset before iterating
56
- // each file so that previous calls don't shift lastIndex.
57
- re.lastIndex = 0;
58
- for (const m of content.matchAll(re)) {
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) {
59
103
  if (perFileHits >= maxHitsPerFile) {
60
104
  warnings.push(`${rel}: capped at ${maxHitsPerFile} hits. Increase maxHitsPerFile to see more.`);
61
105
  break;
62
106
  }
63
- const groups = (m.groups ?? {});
64
- const line = lineNumberAt(content, m.index ?? 0);
65
- const matchStr = m[0];
66
- hits.push({ file: rel, matchStringIndex: i, line, match: matchStr, groups });
67
- extractedDeps.push(buildExtractedDep(rel, line, groups, manager));
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));
68
116
  perFileHits++;
69
117
  }
70
118
  if (perFileHits >= maxHitsPerFile)
71
119
  break;
72
120
  }
73
121
  }
74
- return { filesScanned, filesMatched, hits, extractedDeps, warnings };
122
+ return { filesWalked, filesMatched, hits, extractedDeps, warnings };
75
123
  }
76
124
  function buildExtractedDep(file, line, groups, manager) {
77
125
  const dep = { file, line };
@@ -102,17 +150,9 @@ function buildExtractedDep(file, line, groups, manager) {
102
150
  function applyTemplate(tmpl, groups) {
103
151
  return tmpl.replace(/\{\{\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\}\}/g, (_, name) => groups[name] ?? "");
104
152
  }
105
- function compileRegex(source, extraFlags = "") {
153
+ function validateRegex(source) {
106
154
  try {
107
- // De-duplicate flags in case the source already had `g`/`m`.
108
- const seen = new Set();
109
- const flags = (extraFlags).split("").filter((c) => {
110
- if (seen.has(c))
111
- return false;
112
- seen.add(c);
113
- return true;
114
- }).join("");
115
- return new RegExp(source, flags);
155
+ new RegExp(source);
116
156
  }
117
157
  catch (err) {
118
158
  throw new Error(`Invalid regex /${source}/: ${err.message}`);
@@ -126,6 +166,82 @@ function lineNumberAt(content, index) {
126
166
  }
127
167
  return line;
128
168
  }
169
+ const WORKER_SOURCE = `
170
+ const { parentPort, workerData } = require('node:worker_threads');
171
+ try {
172
+ const { mode, pattern, flags } = workerData;
173
+ const re = new RegExp(pattern, flags);
174
+ if (mode === 'test') {
175
+ const out = [];
176
+ for (const p of workerData.paths) {
177
+ re.lastIndex = 0;
178
+ if (re.test(p)) out.push(p);
179
+ }
180
+ parentPort.postMessage({ ok: true, mode: 'test', paths: out });
181
+ } else {
182
+ const out = [];
183
+ for (const m of workerData.content.matchAll(re)) {
184
+ out.push({
185
+ index: m.index == null ? 0 : m.index,
186
+ match: m[0],
187
+ groups: m.groups == null ? {} : Object.assign({}, m.groups),
188
+ });
189
+ }
190
+ parentPort.postMessage({ ok: true, mode: 'matchAll', matches: out });
191
+ }
192
+ } catch (err) {
193
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
194
+ }
195
+ `;
196
+ async function runWorker(request, timeoutMs) {
197
+ const worker = new Worker(WORKER_SOURCE, {
198
+ eval: true,
199
+ workerData: request,
200
+ });
201
+ let timer;
202
+ try {
203
+ return await new Promise((resolve, reject) => {
204
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
205
+ worker.once("message", (msg) => resolve(msg));
206
+ worker.once("error", (err) => reject(err));
207
+ worker.once("exit", (code) => {
208
+ if (code !== 0 && code !== 1) {
209
+ // code 1 is the normal exit after terminate(); anything else is a
210
+ // crash we haven't already captured via 'error'.
211
+ reject(new Error(`Regex worker exited unexpectedly with code ${code}`));
212
+ }
213
+ });
214
+ });
215
+ }
216
+ finally {
217
+ if (timer)
218
+ clearTimeout(timer);
219
+ // terminate() is idempotent and safe to call after the worker already exited.
220
+ await worker.terminate().catch(() => { });
221
+ }
222
+ }
223
+ async function runTestInWorker(pattern, flags, paths, timeoutMs) {
224
+ const response = await runWorker({ mode: "test", pattern, flags, paths }, timeoutMs);
225
+ if (response === "timeout")
226
+ return { timedOut: true };
227
+ if (!response.ok)
228
+ throw new Error(`Regex worker error: ${response.error}`);
229
+ if (response.mode !== "test") {
230
+ throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
231
+ }
232
+ return { timedOut: false, paths: response.paths };
233
+ }
234
+ async function runMatchAllInWorker(pattern, flags, content, timeoutMs) {
235
+ const response = await runWorker({ mode: "matchAll", pattern, flags, content }, timeoutMs);
236
+ if (response === "timeout")
237
+ return { timedOut: true };
238
+ if (!response.ok)
239
+ throw new Error(`Regex worker error: ${response.error}`);
240
+ if (response.mode !== "matchAll") {
241
+ throw new Error(`Regex worker returned wrong mode: ${response.mode}`);
242
+ }
243
+ return { timedOut: false, matches: response.matches };
244
+ }
129
245
  async function readMaybe(abs) {
130
246
  try {
131
247
  return await fs.readFile(abs, "utf8");