renovate-mcp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Restart your client and try the prompt: *"List the namespaces available under `r
39
39
 
40
40
  ## Tools & resources
41
41
 
42
- Fourteen tools and three resource templates. Each tool name below links to its full reference in [`docs/tools.md`](docs/tools.md).
42
+ Sixteen tools and three resource templates. Each tool name below links to its full reference in [`docs/tools.md`](docs/tools.md).
43
43
 
44
44
  | Tool | Purpose |
45
45
  | --- | --- |
@@ -50,11 +50,13 @@ Fourteen tools and three resource templates. Each tool name below links to its f
50
50
  | [`resolve_config`](docs/tools.md#resolve_config) | Expand every `extends` preset offline. Opt in to fetching `github>` / `gitlab>` presets over HTTPS. |
51
51
  | [`explain_config`](docs/tools.md#explain_config) | Inverse of `resolve_config`: annotate every leaf field with the chain of presets that set it. |
52
52
  | [`resolve_config_diff`](docs/tools.md#resolve_config_diff) | Offline structural diff of two fully-resolved configs — changed fields plus an order-insensitive set diff of array keys (`packageRules`, `customManagers`, …). The refactor-friendly counterpart to `dry_run_diff`. |
53
+ | [`test_package_rules`](docs/tools.md#test_package_rules) | Offline what-if: which `packageRules` match a hypothetical dependency, which matcher decided each, and what each contributes. Faithful (Renovate's real matchers in a worker); fields you don't supply are reported as unevaluatable, not non-matches. |
53
54
  | [`preview_custom_manager`](docs/tools.md#preview_custom_manager) | Preview a `customManagers` entry (regex or JSONata) against a local repo. Offline. |
54
55
  | [`validate_config`](docs/tools.md#validate_config) | Run `renovate-config-validator` against a file or inline object. |
55
56
  | [`lint_config`](docs/tools.md#lint_config) | Semantic lint pass for Renovate-specific footguns the schema validator declares valid. Offline. |
56
57
  | [`dry_run`](docs/tools.md#dry_run) | Run Renovate with `--dry-run` and return the structured JSON report. Local-by-default; remote with `platform` + `endpoint` + `token` + `repository`. No PRs, no pushes. |
57
58
  | [`dry_run_diff`](docs/tools.md#dry_run_diff) | Stateless semantic diff between two `dry_run` reports — added / removed / changed updates. |
59
+ | [`annotate_dry_run`](docs/tools.md#annotate_dry_run) | Attribute each proposed update in a `dry_run` report to the `packageRules` that caused it; flags rules that never matched and report fields the matchers needed but the report lacked. Stateless + offline. |
58
60
  | [`migrate_config`](docs/tools.md#migrate_config) | Apply Renovate's built-in migrations and return the migrated config plus a unified diff. Does not write. |
59
61
  | [`write_config`](docs/tools.md#write_config) | Validate, then atomically write a config to disk. Preserves comments/key order on existing JSON-with-comments files. |
60
62
  | [`renovate://presets`](docs/tools.md#renovatepresets) (resource) | Markdown index of all built-in presets grouped by namespace. |
@@ -1,4 +1,4 @@
1
- export declare const RENOVATE_VERSION = "43.233.4";
1
+ export declare const RENOVATE_VERSION = "43.245.0";
2
2
  /**
3
3
  * All manager names accepted by Renovate's `matchManagers` /
4
4
  * `excludeManagers` fields. Includes both regular managers and custom
@@ -1,6 +1,6 @@
1
- // AUTO-GENERATED by scripts/generate-managers.mjs from renovate@43.233.4.
1
+ // AUTO-GENERATED by scripts/generate-managers.mjs from renovate@43.245.0.
2
2
  // Do not edit by hand. Run `npm run generate:managers` to refresh.
3
- export const RENOVATE_VERSION = "43.233.4";
3
+ export const RENOVATE_VERSION = "43.245.0";
4
4
  /**
5
5
  * All manager names accepted by Renovate's `matchManagers` /
6
6
  * `excludeManagers` fields. Includes both regular managers and custom
@@ -1,4 +1,4 @@
1
- export declare const RENOVATE_VERSION = "43.233.4";
1
+ export declare const RENOVATE_VERSION = "43.245.0";
2
2
  export interface DeprecatedKeyEntry {
3
3
  /** Key as it appeared in old configs. */
4
4
  readonly oldKey: string;
@@ -1,6 +1,6 @@
1
- // AUTO-GENERATED by scripts/generate-migrations.mjs from renovate@43.233.4.
1
+ // AUTO-GENERATED by scripts/generate-migrations.mjs from renovate@43.245.0.
2
2
  // Do not edit by hand. Run `npm run generate:migrations` to refresh.
3
- export const RENOVATE_VERSION = "43.233.4";
3
+ export const RENOVATE_VERSION = "43.245.0";
4
4
  /**
5
5
  * Deprecated config keys with their current replacement. Sourced from
6
6
  * Renovate's `MigrationsService.renamedProperties` — the "RenamePropertyMigration"
@@ -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.233.4";
6
+ export declare const RENOVATE_VERSION = "43.245.0";
7
7
  export declare const PRESETS: Record<string, GeneratedPreset>;
8
8
  export declare const PRESET_NAMES: readonly string[];
@@ -1,6 +1,6 @@
1
- // AUTO-GENERATED by scripts/generate-presets.mjs from renovate@43.233.4.
1
+ // AUTO-GENERATED by scripts/generate-presets.mjs from renovate@43.245.0.
2
2
  // Do not edit by hand. Run `npm run generate:presets` to refresh.
3
- export const RENOVATE_VERSION = "43.233.4";
3
+ export const RENOVATE_VERSION = "43.245.0";
4
4
  export const PRESETS = {
5
5
  "abandonments:recommended": {
6
6
  "namespace": "abandonments",
@@ -17861,6 +17861,7 @@ export const PRESETS = {
17861
17861
  "replacements:actions-attest-build-provenance-to-actions-attest",
17862
17862
  "replacements:airbnb-prop-types-to-prop-types-tools",
17863
17863
  "replacements:apollo-server-to-scoped",
17864
+ "replacements:aws-sdk-smithy-client-to-smithy",
17864
17865
  "replacements:babel-eslint-to-eslint-parser",
17865
17866
  "replacements:base-ui-components-to-base-ui",
17866
17867
  "replacements:containerbase",
@@ -18011,6 +18012,24 @@ export const PRESETS = {
18011
18012
  ]
18012
18013
  }
18013
18014
  },
18015
+ "replacements:aws-sdk-smithy-client-to-smithy": {
18016
+ "namespace": "replacements",
18017
+ "description": "`@aws-sdk/smithy-client` was moved to `@smithy/smithy-client`.",
18018
+ "body": {
18019
+ "packageRules": [
18020
+ {
18021
+ "matchDatasources": [
18022
+ "npm"
18023
+ ],
18024
+ "matchPackageNames": [
18025
+ "@aws-sdk/smithy-client"
18026
+ ],
18027
+ "replacementName": "@smithy/smithy-client",
18028
+ "replacementVersion": "1.0.5"
18029
+ }
18030
+ ]
18031
+ }
18032
+ },
18014
18033
  "replacements:babel-eslint-to-eslint-parser": {
18015
18034
  "namespace": "replacements",
18016
18035
  "description": "`babel-eslint` was renamed under the `@babel` scope.",
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ import { registerSuggestPresets } from "./tools/suggestPresets.js";
12
12
  import { registerPreviewCustomManager } from "./tools/previewCustomManager.js";
13
13
  import { registerDryRun } from "./tools/dryRun.js";
14
14
  import { registerDryRunDiff } from "./tools/dryRunDiff.js";
15
+ import { registerTestPackageRules } from "./tools/testPackageRules.js";
16
+ import { registerAnnotateDryRun } from "./tools/annotateDryRun.js";
15
17
  import { registerWriteConfig } from "./tools/writeConfig.js";
16
18
  import { registerMigrateConfig } from "./tools/migrateConfig.js";
17
19
  import { registerGetVersion } from "./tools/getVersion.js";
@@ -37,13 +39,15 @@ const BASE_INSTRUCTIONS = [
37
39
  " 3. resolve_config — expand built-in presets to see what a config actually becomes (offline)",
38
40
  " 4. explain_config — inverse of resolve_config: trace which preset set each field (offline)",
39
41
  " 5. resolve_config_diff — offline structural diff of two resolved configs (changed fields + array/packageRules set diffs); use this over dry_run_diff for config refactors, especially when registries are unreachable",
40
- " 6. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
41
- " 7. validate_config — check a proposed config against Renovate's schema",
42
- " 8. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
43
- " 9. dry_run — preview what Renovate would actually do (no PRs)",
44
- " 10. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
45
- " 11. migrate_config — apply Renovate's built-in migrations (deprecated keys → current schema) and return the migrated config",
46
- " 12. write_config — save the agreed-upon config (validates first)",
42
+ " 6. test_package_rules — offline what-if: which packageRules match a hypothetical dependency, which matcher decided each, and what each contributes (debug \"why didn't my rule match?\")",
43
+ " 7. preview_custom_manager — iterate on a regex-based customManagers entry; shows file/line hits and extracted deps",
44
+ " 8. validate_config — check a proposed config against Renovate's schema",
45
+ " 9. lint_config — semantic lint pass: catches Renovate-specific footguns schema validation misses (e.g. malformed /…/ regex patterns)",
46
+ " 10. dry_run — preview what Renovate would actually do (no PRs)",
47
+ " 11. dry_run_diff — semantic diff between two dry_run reports (added/removed/changed updates)",
48
+ " 12. annotate_dry_run — attribute each proposed update in a dry_run report to the packageRules that caused it (and flag rules that never matched)",
49
+ " 13. migrate_config — apply Renovate's built-in migrations (deprecated keys → current schema) and return the migrated config",
50
+ " 14. write_config — save the agreed-upon config (validates first)",
47
51
  "",
48
52
  "Before the first repo-touching tool call in a session (read_config, resolve_config, dry_run, write_config, …), call check_setup with the same repoPath. It surfaces token / endpoint / connectivity problems up front (e.g. \"set GITHUB_TOKEN or github-actions deps will be skipped\") instead of waiting for dry_run to fail. Skip if the user has already confirmed setup or is doing offline-only work that doesn't depend on git origin or registries.",
49
53
  "If any tool fails unexpectedly, call check_setup to diagnose CLI availability.",
@@ -72,6 +76,8 @@ registerValidateConfig(server);
72
76
  registerLintConfig(server);
73
77
  registerDryRun(server);
74
78
  registerDryRunDiff(server);
79
+ registerTestPackageRules(server);
80
+ registerAnnotateDryRun(server);
75
81
  registerMigrateConfig(server);
76
82
  registerWriteConfig(server);
77
83
  registerGetVersion(server);
@@ -61,5 +61,10 @@ export interface DryRunDiff {
61
61
  * would actually become a PR — which is what the diff is about.
62
62
  */
63
63
  export declare function collectProposedUpdates(report: unknown): ProposedUpdate[];
64
+ export declare function identityKey(u: {
65
+ manager: string;
66
+ packageFile: string;
67
+ depName: string;
68
+ }): string;
64
69
  export declare function diffDryRunReports(beforeInput: unknown, afterInput: unknown): DryRunDiff;
65
70
  export {};
Binary file
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared analysis layer for `test_package_rules` and `annotate_dry_run`. Wraps
3
+ * the worker-isolated faithful matcher (see ADR-0006) and turns its raw
4
+ * per-matcher verdict vector into a human-meaningful classification:
5
+ * - matched — the matcher returned true
6
+ * - real non-match — the matcher returned false AND the caller supplied
7
+ * the field(s) it reads (so the verdict is trustworthy)
8
+ * - unevaluatable — the matcher returned false but a field it needs was
9
+ * not supplied, OR it threw (matchConfidence needs the
10
+ * merge-confidence API), OR it is a JSONata expression
11
+ * whose result may depend on unsupplied computed fields
12
+ *
13
+ * The crucial subtlety (verified in Renovate's matchers): a matcher returns
14
+ * `false` identically for "supplied-but-no-match" and "field-absent". Only this
15
+ * layer — which knows what the caller actually supplied — can tell them apart.
16
+ *
17
+ * If the worker is unavailable, falls back to a thin in-process glob-only matcher
18
+ * over the common static keys and reports `matchQuality: "preview"`, mirroring
19
+ * resolve_config / explain_config.
20
+ */
21
+ export type MatchQuality = "faithful" | "preview";
22
+ type MatcherKind = "static" | "computed" | "mc-api" | "jsonata";
23
+ interface MatcherMeta {
24
+ /** Friendly `match*` key as written in a packageRule. */
25
+ key: string;
26
+ /** Context fields the matcher reads; evaluatable iff ≥1 is supplied. */
27
+ requiredAnyOf: string[];
28
+ kind: MatcherKind;
29
+ }
30
+ /**
31
+ * Class-name → metadata for every matcher in Renovate's registry. The keys are
32
+ * tied to the matcher-registry drift sentinel (see packageRulesWorker.test.ts):
33
+ * a Renovate bump that renames/adds/removes a matcher breaks both this map's
34
+ * coverage assertion and the sentinel, forcing a deliberate update with ADR-0006.
35
+ */
36
+ export declare const MATCHER_META: Record<string, MatcherMeta>;
37
+ export type UnevaluatableReason = "missing-input-field" | "needs-merge-confidence-api" | "jsonata-may-reference-computed-fields" | "matcher-error";
38
+ export interface MatcherFinding {
39
+ /** Friendly `match*` key. */
40
+ matcher: string;
41
+ /** Context fields the matcher reads (when relevant). */
42
+ requiredFields: string[];
43
+ reason: UnevaluatableReason;
44
+ /** Present for matcher-error / needs-merge-confidence-api. */
45
+ error?: string;
46
+ }
47
+ export interface RuleAnalysis {
48
+ index: number;
49
+ rule: Record<string, unknown>;
50
+ /** True iff the rule cleanly matched (no matcher returned false, none threw). */
51
+ matched: boolean;
52
+ /** Friendly keys that returned true. */
53
+ matchedBy: string[];
54
+ /** Friendly keys that returned a trustworthy false (their fields were supplied). */
55
+ decidedBy: string[];
56
+ /** Matchers we could not evaluate (missing field, mc-api, jsonata advisory, error). */
57
+ unevaluatable: MatcherFinding[];
58
+ /** The rule's contribution (match/exclude stripped, overrides applied). Present only when matched. */
59
+ contributedConfig?: Record<string, unknown>;
60
+ }
61
+ export interface ContextAnalysis {
62
+ /** Faithful merged config (worker path) or approximate (preview path). */
63
+ mergedConfig: Record<string, unknown>;
64
+ rules: RuleAnalysis[];
65
+ }
66
+ export interface PackageRulesAnalysis {
67
+ matchQuality: MatchQuality;
68
+ contexts: ContextAnalysis[];
69
+ warnings: string[];
70
+ }
71
+ /** Field names the caller actually supplied (present and not undefined). */
72
+ export declare function suppliedFieldsOf(context: Record<string, unknown>): Set<string>;
73
+ /**
74
+ * Detect deprecated matcher keys. Renovate migrates these to matchPackageNames /
75
+ * matchFileNames internally before applying rules; the real matchers we evaluate
76
+ * only understand the migrated keys, so a legacy rule would faithfully match
77
+ * nothing. We warn and point at migrate_config (consistent with
78
+ * lint_config → migrate_config) rather than auto-migrating.
79
+ */
80
+ export declare function detectLegacyKeys(packageRules: Record<string, unknown>[]): string[];
81
+ /**
82
+ * Evaluate `packageRules` against each context and classify the results. The
83
+ * caller passes the same `contexts` it would hand the worker; `suppliedFieldsOf`
84
+ * is applied per context so false-vs-missing-field can be distinguished.
85
+ */
86
+ export declare function analyzePackageRules(packageRules: Record<string, unknown>[], contexts: Record<string, unknown>[], options?: {
87
+ timeoutMs?: number;
88
+ }): Promise<PackageRulesAnalysis>;
89
+ export {};
@@ -0,0 +1,252 @@
1
+ import { runApplyPackageRules, } from "./packageRulesWorker.js";
2
+ /**
3
+ * Class-name → metadata for every matcher in Renovate's registry. The keys are
4
+ * tied to the matcher-registry drift sentinel (see packageRulesWorker.test.ts):
5
+ * a Renovate bump that renames/adds/removes a matcher breaks both this map's
6
+ * coverage assertion and the sentinel, forcing a deliberate update with ADR-0006.
7
+ */
8
+ export const MATCHER_META = {
9
+ MergeConfidenceMatcher: { key: "matchConfidence", requiredAnyOf: ["mergeConfidenceLevel"], kind: "mc-api" },
10
+ RepositoriesMatcher: { key: "matchRepositories", requiredAnyOf: ["repository"], kind: "static" },
11
+ BaseBranchesMatcher: { key: "matchBaseBranches", requiredAnyOf: ["baseBranch"], kind: "static" },
12
+ CategoriesMatcher: { key: "matchCategories", requiredAnyOf: ["categories"], kind: "static" },
13
+ ManagersMatcher: { key: "matchManagers", requiredAnyOf: ["manager"], kind: "static" },
14
+ FileNamesMatcher: { key: "matchFileNames", requiredAnyOf: ["packageFile", "lockFiles"], kind: "static" },
15
+ DatasourcesMatcher: { key: "matchDatasources", requiredAnyOf: ["datasource"], kind: "static" },
16
+ PackageNameMatcher: { key: "matchPackageNames", requiredAnyOf: ["packageName", "depName"], kind: "static" },
17
+ DepNameMatcher: { key: "matchDepNames", requiredAnyOf: ["depName"], kind: "static" },
18
+ DepTypesMatcher: { key: "matchDepTypes", requiredAnyOf: ["depType", "depTypes"], kind: "static" },
19
+ CurrentValueMatcher: { key: "matchCurrentValue", requiredAnyOf: ["currentValue"], kind: "static" },
20
+ CurrentVersionMatcher: { key: "matchCurrentVersion", requiredAnyOf: ["currentVersion", "lockedVersion"], kind: "computed" },
21
+ UpdateTypesMatcher: { key: "matchUpdateTypes", requiredAnyOf: ["updateType", "isBump"], kind: "computed" },
22
+ SourceUrlsMatcher: { key: "matchSourceUrls", requiredAnyOf: ["sourceUrl"], kind: "static" },
23
+ RegistryUrlsMatcher: { key: "matchRegistryUrls", requiredAnyOf: ["registryUrls"], kind: "static" },
24
+ NewValueMatcher: { key: "matchNewValue", requiredAnyOf: ["newValue"], kind: "computed" },
25
+ CurrentAgeMatcher: { key: "matchCurrentAge", requiredAnyOf: ["currentVersionTimestamp"], kind: "computed" },
26
+ JsonataMatcher: { key: "matchJsonata", requiredAnyOf: [], kind: "jsonata" },
27
+ };
28
+ const LEGACY_MATCHER_KEYS = [
29
+ "matchPackagePatterns",
30
+ "matchPackagePrefixes",
31
+ "excludePackagePatterns",
32
+ "excludePackagePrefixes",
33
+ "matchPaths",
34
+ "paths",
35
+ ];
36
+ /** Field names the caller actually supplied (present and not undefined). */
37
+ export function suppliedFieldsOf(context) {
38
+ const set = new Set();
39
+ for (const [k, v] of Object.entries(context)) {
40
+ if (v !== undefined)
41
+ set.add(k);
42
+ }
43
+ return set;
44
+ }
45
+ /**
46
+ * Detect deprecated matcher keys. Renovate migrates these to matchPackageNames /
47
+ * matchFileNames internally before applying rules; the real matchers we evaluate
48
+ * only understand the migrated keys, so a legacy rule would faithfully match
49
+ * nothing. We warn and point at migrate_config (consistent with
50
+ * lint_config → migrate_config) rather than auto-migrating.
51
+ */
52
+ export function detectLegacyKeys(packageRules) {
53
+ const found = new Set();
54
+ for (const rule of packageRules) {
55
+ for (const key of LEGACY_MATCHER_KEYS) {
56
+ if (key in rule)
57
+ found.add(key);
58
+ }
59
+ }
60
+ if (found.size === 0)
61
+ return [];
62
+ return [
63
+ `Config uses deprecated matcher key(s) ${[...found]
64
+ .map((k) => `\`${k}\``)
65
+ .join(", ")}. Renovate migrates these before applying rules, but this tool ` +
66
+ `evaluates the real (post-migration) matchers, so those rules will appear to ` +
67
+ `match nothing. Run migrate_config first, then re-run this tool.`,
68
+ ];
69
+ }
70
+ /**
71
+ * Evaluate `packageRules` against each context and classify the results. The
72
+ * caller passes the same `contexts` it would hand the worker; `suppliedFieldsOf`
73
+ * is applied per context so false-vs-missing-field can be distinguished.
74
+ */
75
+ export async function analyzePackageRules(packageRules, contexts, options = {}) {
76
+ const warnings = detectLegacyKeys(packageRules);
77
+ let workerResults;
78
+ try {
79
+ workerResults = await runApplyPackageRules(packageRules, contexts, options);
80
+ }
81
+ catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ warnings.push(`Faithful packageRules worker unavailable (${message}); used an approximate ` +
84
+ `glob-only preview that only evaluates matchPackageNames / matchDepNames / ` +
85
+ `matchManagers / matchDatasources / matchFileNames / matchCategories. Run ` +
86
+ `dry_run for authoritative output.`);
87
+ return {
88
+ matchQuality: "preview",
89
+ warnings,
90
+ contexts: contexts.map((ctx) => previewContext(packageRules, ctx)),
91
+ };
92
+ }
93
+ const analysed = workerResults.map((res, i) => {
94
+ const supplied = suppliedFieldsOf(contexts[i] ?? {});
95
+ return {
96
+ mergedConfig: res.mergedConfig,
97
+ rules: res.rules.map((ruleResult, index) => classifyRule(ruleResult, packageRules[index] ?? {}, index, supplied)),
98
+ };
99
+ });
100
+ return { matchQuality: "faithful", warnings, contexts: analysed };
101
+ }
102
+ function classifyRule(ruleResult, rule, index, supplied) {
103
+ const matchedBy = [];
104
+ const decidedBy = [];
105
+ const unevaluatable = [];
106
+ for (const m of ruleResult.matchers) {
107
+ const meta = MATCHER_META[m.name];
108
+ const key = meta?.key ?? m.name;
109
+ const requiredFields = meta?.requiredAnyOf ?? [];
110
+ if (m.result === "null")
111
+ continue; // matcher not used by this rule
112
+ if (m.result === "threw") {
113
+ unevaluatable.push({
114
+ matcher: key,
115
+ requiredFields,
116
+ reason: meta?.kind === "mc-api" ? "needs-merge-confidence-api" : "matcher-error",
117
+ ...(m.error ? { error: m.error } : {}),
118
+ });
119
+ continue;
120
+ }
121
+ if (meta?.kind === "jsonata") {
122
+ // JSONata can read any field; its verdict may depend on unsupplied
123
+ // computed context. Surface as advisory regardless of true/false.
124
+ unevaluatable.push({
125
+ matcher: key,
126
+ requiredFields: [],
127
+ reason: "jsonata-may-reference-computed-fields",
128
+ });
129
+ if (m.result === "true")
130
+ matchedBy.push(key);
131
+ continue;
132
+ }
133
+ if (m.result === "true") {
134
+ matchedBy.push(key);
135
+ continue;
136
+ }
137
+ // result === "false": trustworthy only if a field it reads was supplied.
138
+ const evaluatable = requiredFields.some((f) => supplied.has(f));
139
+ if (evaluatable) {
140
+ decidedBy.push(key);
141
+ }
142
+ else {
143
+ // The only way to reach here is a missing input field. mc-api matchers
144
+ // (matchConfidence) reach `unevaluatable` via the "threw" branch above —
145
+ // they throw offline rather than returning false — so the
146
+ // "needs-merge-confidence-api" reason belongs there, never here.
147
+ unevaluatable.push({ matcher: key, requiredFields, reason: "missing-input-field" });
148
+ }
149
+ }
150
+ return {
151
+ index,
152
+ rule,
153
+ matched: ruleResult.matched,
154
+ matchedBy,
155
+ decidedBy,
156
+ unevaluatable,
157
+ ...(ruleResult.contributedConfig
158
+ ? { contributedConfig: ruleResult.contributedConfig }
159
+ : {}),
160
+ };
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Preview fallback: thin in-process glob-only matcher over the common static
164
+ // keys. Deliberately approximate — only reached when the worker is unavailable.
165
+ // ---------------------------------------------------------------------------
166
+ const PREVIEW_SUPPORTED = {
167
+ matchPackageNames: { field: "packageName" },
168
+ matchDepNames: { field: "depName" },
169
+ matchManagers: { field: "manager" },
170
+ matchDatasources: { field: "datasource" },
171
+ matchFileNames: { field: "packageFile" },
172
+ matchCategories: { field: "categories", array: true },
173
+ };
174
+ function escapeRegex(s) {
175
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
176
+ }
177
+ function globEquals(pattern, value) {
178
+ if (!pattern.includes("*"))
179
+ return pattern === value;
180
+ const re = new RegExp(`^${pattern.split("*").map(escapeRegex).join(".*")}$`);
181
+ return re.test(value);
182
+ }
183
+ function globListMatches(patterns, value) {
184
+ if (!Array.isArray(patterns) || typeof value !== "string")
185
+ return false;
186
+ return patterns.some((p) => typeof p === "string" && globEquals(p, value));
187
+ }
188
+ function previewContext(packageRules, context) {
189
+ const supplied = suppliedFieldsOf(context);
190
+ const rules = packageRules.map((rule, index) => {
191
+ const matchedBy = [];
192
+ const decidedBy = [];
193
+ const unevaluatable = [];
194
+ let unmatched = false;
195
+ for (const key of Object.keys(rule)) {
196
+ if (!key.startsWith("match") && !key.startsWith("exclude"))
197
+ continue;
198
+ const supported = PREVIEW_SUPPORTED[key];
199
+ if (!supported) {
200
+ unevaluatable.push({
201
+ matcher: key,
202
+ requiredFields: [],
203
+ reason: "matcher-error",
204
+ error: "not evaluated in preview mode (worker unavailable)",
205
+ });
206
+ continue;
207
+ }
208
+ const value = context[supported.field];
209
+ const ok = supported.array
210
+ ? Array.isArray(value) &&
211
+ value.some((v) => globListMatches(rule[key], v))
212
+ : globListMatches(rule[key], value);
213
+ if (ok) {
214
+ matchedBy.push(key);
215
+ }
216
+ else if (supplied.has(supported.field)) {
217
+ // Field supplied but didn't match → a trustworthy non-match.
218
+ decidedBy.push(key);
219
+ unmatched = true;
220
+ }
221
+ else {
222
+ // Field absent → can't decide (same distinction the faithful path makes).
223
+ unevaluatable.push({
224
+ matcher: key,
225
+ requiredFields: [supported.field],
226
+ reason: "missing-input-field",
227
+ });
228
+ }
229
+ }
230
+ // Match only if no supported matcher rejected AND nothing was unevaluatable.
231
+ const matched = !unmatched && unevaluatable.length === 0;
232
+ return {
233
+ index,
234
+ rule,
235
+ matched,
236
+ matchedBy,
237
+ decidedBy,
238
+ unevaluatable,
239
+ ...(matched ? { contributedConfig: stripMatchers(rule) } : {}),
240
+ };
241
+ });
242
+ return { mergedConfig: { ...context }, rules };
243
+ }
244
+ function stripMatchers(rule) {
245
+ const out = {};
246
+ for (const [k, v] of Object.entries(rule)) {
247
+ if (k.startsWith("match") || k.startsWith("exclude"))
248
+ continue;
249
+ out[k] = v;
250
+ }
251
+ return out;
252
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Evaluates Renovate's real `packageRules` matchers against one or more
3
+ * dependency contexts in a worker thread, isolating the main-process cost of
4
+ * importing `renovate`'s matcher registry (which transitively pulls in the
5
+ * versioning subsystem, JSONata, etc.). The whole batch is evaluated in ONE
6
+ * worker round-trip, so `annotate_dry_run` with N updates pays at most one cold
7
+ * start.
8
+ *
9
+ * Same worker-isolation invariant as `migrate_config` / the merge worker: the
10
+ * main process never imports `renovate`. See ADR-0006.
11
+ *
12
+ * The worker REPLICATES `applyPackageRules`' loop (rather than calling it as a
13
+ * black box) so it can return per-rule / per-matcher provenance Renovate exposes
14
+ * nowhere; see `packageRulesWorkerImpl.ts`.
15
+ */
16
+ export type MatcherResultValue = "null" | "true" | "false" | "threw";
17
+ export interface MatcherResult {
18
+ /** The matcher's class name, e.g. `DepNameMatcher`. */
19
+ name: string;
20
+ result: MatcherResultValue;
21
+ /** Present only when `result === "threw"`. */
22
+ error?: string;
23
+ }
24
+ export interface PerRuleResult {
25
+ /** True iff no applicable matcher returned false and none threw. */
26
+ matched: boolean;
27
+ matchers: MatcherResult[];
28
+ /** The rule's contribution (match/exclude keys stripped, overrides applied). Present only when matched. */
29
+ contributedConfig?: Record<string, unknown>;
30
+ }
31
+ export interface PerContextResult {
32
+ /** Faithful merged config — bit-identical to applyPackageRules for matchConfidence-free configs. */
33
+ mergedConfig: Record<string, unknown>;
34
+ rules: PerRuleResult[];
35
+ }
36
+ export declare class PackageRulesTimeoutError extends Error {
37
+ readonly timeoutMs: number;
38
+ constructor(timeoutMs: number);
39
+ }
40
+ export interface RunPackageRulesOptions {
41
+ timeoutMs?: number;
42
+ }
43
+ /**
44
+ * Evaluate `packageRules` against each of `contexts`, returning one result per
45
+ * context (faithful merged config + per-rule provenance). Throws
46
+ * `PackageRulesTimeoutError` on timeout or a plain Error on worker failure — the
47
+ * caller (packageRulesAnalysis) catches and degrades to a preview fallback.
48
+ */
49
+ export declare function runApplyPackageRules(packageRules: Record<string, unknown>[], contexts: Record<string, unknown>[], options?: RunPackageRulesOptions): Promise<PerContextResult[]>;
@@ -0,0 +1,82 @@
1
+ import { Worker } from "node:worker_threads";
2
+ // First-call latency budget: ESM cold-load of renovate's matcher registry (which
3
+ // pulls in the versioning subsystem) can take a couple of seconds on slow CI.
4
+ // Each call spawns a fresh worker, so this budget covers the cold path.
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+ /**
7
+ * Worker entry, resolved per call. Overridable via
8
+ * `RENOVATE_MCP_PACKAGE_RULES_WORKER_ENTRY` so the test suite — which loads this
9
+ * module as TS source under `src/`, where the sibling compiled `.js` does not
10
+ * exist — can point at `dist/lib/packageRulesWorkerImpl.js` (built by `pretest`),
11
+ * and so a test can point it at a bogus path to exercise the fallback. In
12
+ * production, `import.meta.url` already resolves to `dist/lib/`.
13
+ */
14
+ function workerEntry() {
15
+ return (process.env.RENOVATE_MCP_PACKAGE_RULES_WORKER_ENTRY ??
16
+ new URL("./packageRulesWorkerImpl.js", import.meta.url));
17
+ }
18
+ export class PackageRulesTimeoutError extends Error {
19
+ timeoutMs;
20
+ constructor(timeoutMs) {
21
+ super(`Renovate packageRules worker exceeded ${timeoutMs}ms`);
22
+ this.name = "PackageRulesTimeoutError";
23
+ this.timeoutMs = timeoutMs;
24
+ }
25
+ }
26
+ /**
27
+ * Evaluate `packageRules` against each of `contexts`, returning one result per
28
+ * context (faithful merged config + per-rule provenance). Throws
29
+ * `PackageRulesTimeoutError` on timeout or a plain Error on worker failure — the
30
+ * caller (packageRulesAnalysis) catches and degrades to a preview fallback.
31
+ */
32
+ export async function runApplyPackageRules(packageRules, contexts, options = {}) {
33
+ // Nothing to evaluate: skip the worker cold start entirely. With no rules,
34
+ // every context's merged config is itself and no rule matched.
35
+ if (contexts.length === 0)
36
+ return [];
37
+ if (packageRules.length === 0) {
38
+ return contexts.map((ctx) => ({
39
+ mergedConfig: { ...ctx, packageRules: [] },
40
+ rules: [],
41
+ }));
42
+ }
43
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
44
+ const worker = new Worker(workerEntry(), {
45
+ workerData: { packageRules, contexts },
46
+ // Isolate the worker's stdio — importing renovate pulls in its logger, and
47
+ // nothing from the worker may leak onto the parent's stdout JSON-RPC channel.
48
+ stdout: true,
49
+ stderr: true,
50
+ });
51
+ let timer;
52
+ try {
53
+ const response = await new Promise((resolve, reject) => {
54
+ // First event wins. The `exit` handler must reject when it fires before
55
+ // a message/error/timeout — a worker that crashes or exits before
56
+ // posting would otherwise leave the promise pending until the timeout.
57
+ let settled = false;
58
+ const settle = (fn) => {
59
+ if (settled)
60
+ return;
61
+ settled = true;
62
+ fn();
63
+ };
64
+ timer = setTimeout(() => settle(() => resolve("timeout")), timeoutMs);
65
+ worker.once("message", (msg) => settle(() => resolve(msg)));
66
+ worker.once("error", (err) => settle(() => reject(err)));
67
+ worker.once("exit", (code) => settle(() => reject(new Error(`packageRules worker exited (code ${code}) before returning a result`))));
68
+ });
69
+ if (response === "timeout") {
70
+ throw new PackageRulesTimeoutError(timeoutMs);
71
+ }
72
+ if (!response.ok) {
73
+ throw new Error(`Renovate packageRules evaluation failed: ${response.error}`);
74
+ }
75
+ return response.results;
76
+ }
77
+ finally {
78
+ if (timer)
79
+ clearTimeout(timer);
80
+ await worker.terminate().catch(() => undefined);
81
+ }
82
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ const { packageRules, contexts } = workerData;
3
+ function asRecord(value) {
4
+ return value && typeof value === "object" && !Array.isArray(value)
5
+ ? value
6
+ : undefined;
7
+ }
8
+ /** Mirror of Renovate's `removeMatchers` (dist/util/package-rules/index.js). */
9
+ function removeMatchers(rule) {
10
+ for (const key of Object.keys(rule)) {
11
+ if (key.startsWith("match") || key.startsWith("exclude"))
12
+ delete rule[key];
13
+ }
14
+ return rule;
15
+ }
16
+ try {
17
+ const matchers = (await import("renovate/dist/util/package-rules/matchers.js")).default;
18
+ const { mergeChildConfig } = (await import("renovate/dist/config/utils.js"));
19
+ const { compile } = (await import("renovate/dist/util/template/index.js"));
20
+ const slugify = (await import("slugify")).default;
21
+ const results = [];
22
+ for (const context of contexts) {
23
+ // Renovate's applyPackageRules reads `config.packageRules` off the input.
24
+ let config = { ...context, packageRules };
25
+ const rules = [];
26
+ for (const rule of packageRules) {
27
+ const matcherResults = [];
28
+ let anyFalse = false;
29
+ let anyThrew = false;
30
+ for (const matcher of matchers) {
31
+ const name = matcher.constructor.name;
32
+ try {
33
+ const verdict = await matcher.matches(config, rule);
34
+ if (verdict === null || verdict === undefined) {
35
+ matcherResults.push({ name, result: "null" });
36
+ }
37
+ else if (verdict) {
38
+ matcherResults.push({ name, result: "true" });
39
+ }
40
+ else {
41
+ matcherResults.push({ name, result: "false" });
42
+ anyFalse = true;
43
+ }
44
+ }
45
+ catch (err) {
46
+ matcherResults.push({
47
+ name,
48
+ result: "threw",
49
+ error: err instanceof Error ? err.message : String(err),
50
+ });
51
+ anyThrew = true;
52
+ }
53
+ }
54
+ const matched = !anyFalse && !anyThrew;
55
+ let contributedConfig;
56
+ if (matched) {
57
+ // Replicate the merge in dist/util/package-rules/index.js exactly so
58
+ // `mergedConfig` matches applyPackageRules. Overrides mutate `config`
59
+ // mid-loop, so a later rule sees an earlier rule's overrides.
60
+ const toApply = removeMatchers({ ...rule });
61
+ const force = asRecord(toApply.force);
62
+ if (config.groupSlug && rule.groupName && !rule.groupSlug) {
63
+ toApply.groupSlug = slugify(String(rule.groupName), { lower: true });
64
+ }
65
+ if (force?.enabled === false ||
66
+ (toApply.enabled === false && config.enabled !== false)) {
67
+ config.skipReason = "package-rules";
68
+ }
69
+ if (force?.enabled || toApply.enabled) {
70
+ delete config.skipReason;
71
+ delete config.skipStage;
72
+ }
73
+ if (typeof toApply.overrideDatasource === "string" &&
74
+ toApply.overrideDatasource !== config.datasource) {
75
+ config.datasource = toApply.overrideDatasource;
76
+ }
77
+ if (typeof toApply.overrideDepName === "string" &&
78
+ toApply.overrideDepName !== config.depName) {
79
+ config.depName = compile(toApply.overrideDepName, config);
80
+ }
81
+ if (typeof toApply.overridePackageName === "string" &&
82
+ toApply.overridePackageName !== config.packageName) {
83
+ config.packageName = compile(toApply.overridePackageName, config);
84
+ }
85
+ if (typeof toApply.sourceUrl === "string") {
86
+ toApply.sourceUrl = compile(toApply.sourceUrl, config);
87
+ }
88
+ delete toApply.overrideDatasource;
89
+ delete toApply.overrideDepName;
90
+ delete toApply.overridePackageName;
91
+ contributedConfig = structuredClone(toApply);
92
+ config = mergeChildConfig(config, toApply);
93
+ }
94
+ rules.push({
95
+ matched,
96
+ matchers: matcherResults,
97
+ ...(contributedConfig ? { contributedConfig } : {}),
98
+ });
99
+ }
100
+ results.push({ mergedConfig: config, rules });
101
+ }
102
+ parentPort.postMessage({ ok: true, results });
103
+ }
104
+ catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ parentPort.postMessage({ ok: false, error: message });
107
+ }
@@ -1,4 +1,4 @@
1
- export declare const SERVER_VERSION = "1.2.0";
1
+ export declare const SERVER_VERSION = "1.3.0";
2
2
  export type BuildMode = "local" | "released";
3
3
  export interface VersionInfo {
4
4
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- export const SERVER_VERSION = "1.2.0"; // x-release-please-version
2
+ export const SERVER_VERSION = "1.3.0"; // x-release-please-version
3
3
  // "released" = the server is running from a `node_modules/` install (global
4
4
  // `npm i -g`, project-local `npm i`, or `npx renovate-mcp`). Anything else —
5
5
  // most commonly an MCP config of `{"command": "node", "args": [".../dist/index.js"]}`
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerAnnotateDryRun(server: McpServer): void;
@@ -0,0 +1,248 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { z } from "zod";
3
+ import { locateConfig } from "../lib/configLocations.js";
4
+ import { resolveConfig } from "../lib/presetResolver.js";
5
+ import { identityKey } from "../lib/dryRunDiff.js";
6
+ import { analyzePackageRules, MATCHER_META, } from "../lib/packageRulesAnalysis.js";
7
+ import { configRecord, endpointString, pathString, reportRecord } from "../lib/inputLimits.js";
8
+ const FAITHFUL_DISCLAIMER = "Each update is attributed using Renovate's real matchers (run in a worker thread) against the facts present in the dry_run report. Matchers needing data the report doesn't carry are reported under each update's `unevaluatable` and aggregated in `fieldGaps`, NOT as non-matches. matchConfidence needs the merge-confidence API (unevaluatable offline); matchJsonata is advisory. Run dry_run for full-fidelity resolution.";
9
+ const PREVIEW_DISCLAIMER = "The faithful matcher worker was unavailable, so this used an approximate glob-only preview (matchPackageNames / matchDepNames / matchManagers / matchDatasources / matchFileNames / matchCategories only); rules using any other matcher are reported as unevaluatable. Run dry_run for authoritative output.";
10
+ // Matcher-relevant fields we try to lift off each report upgrade entry.
11
+ const CONTEXT_FIELDS = [
12
+ "depName", "packageName", "datasource", "manager", "depType", "depTypes",
13
+ "currentValue", "currentVersion", "lockedVersion", "versioning", "packageFile",
14
+ "lockFiles", "categories", "sourceUrl", "updateType", "newValue", "newVersion",
15
+ "currentVersionTimestamp", "registryUrls", "baseBranch", "repository", "isBump",
16
+ ];
17
+ function extractReport(input) {
18
+ if (input && typeof input === "object" && !Array.isArray(input)) {
19
+ const obj = input;
20
+ if ("report" in obj && obj.report && typeof obj.report === "object") {
21
+ return obj.report;
22
+ }
23
+ }
24
+ return input;
25
+ }
26
+ function readRecord(node, key) {
27
+ if (!node || typeof node !== "object")
28
+ return null;
29
+ const value = node[key];
30
+ if (!value || typeof value !== "object" || Array.isArray(value))
31
+ return null;
32
+ return value;
33
+ }
34
+ function readArray(node, key) {
35
+ if (!node || typeof node !== "object")
36
+ return null;
37
+ const value = node[key];
38
+ return Array.isArray(value) ? value : null;
39
+ }
40
+ /** Walk repositories[*].branches[*].upgrades[] and lift matcher-relevant facts. */
41
+ function collectUpdateEntries(report) {
42
+ const out = [];
43
+ const repositories = readRecord(report, "repositories");
44
+ if (!repositories)
45
+ return out;
46
+ for (const repo of Object.values(repositories)) {
47
+ const branches = readArray(repo, "branches");
48
+ if (!branches)
49
+ continue;
50
+ for (const branch of branches) {
51
+ const upgrades = readArray(branch, "upgrades");
52
+ if (!upgrades)
53
+ continue;
54
+ for (const upgrade of upgrades) {
55
+ if (!upgrade || typeof upgrade !== "object")
56
+ continue;
57
+ const u = upgrade;
58
+ const manager = typeof u.manager === "string" ? u.manager : "";
59
+ const depName = typeof u.depName === "string" ? u.depName : "";
60
+ if (!manager || !depName)
61
+ continue;
62
+ const packageFile = typeof u.packageFile === "string" ? u.packageFile : "";
63
+ const context = {};
64
+ for (const field of CONTEXT_FIELDS) {
65
+ if (u[field] !== undefined)
66
+ context[field] = u[field];
67
+ }
68
+ out.push({
69
+ display: {
70
+ manager,
71
+ packageFile,
72
+ depName,
73
+ currentVersion: u.currentVersion ?? u.currentValue,
74
+ newVersion: u.newVersion ?? u.newValue,
75
+ updateType: u.updateType,
76
+ },
77
+ context,
78
+ });
79
+ }
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+ async function readReportPath(reportPath) {
85
+ let raw;
86
+ try {
87
+ raw = await fs.readFile(reportPath, "utf8");
88
+ }
89
+ catch (err) {
90
+ const msg = err instanceof Error ? err.message : String(err);
91
+ return { ok: false, error: `Could not read reportPath (\`${reportPath}\`): ${msg}.` };
92
+ }
93
+ try {
94
+ return { ok: true, value: JSON.parse(raw) };
95
+ }
96
+ catch (err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return { ok: false, error: `reportPath (\`${reportPath}\`) is not valid JSON: ${msg}.` };
99
+ }
100
+ }
101
+ export function registerAnnotateDryRun(server) {
102
+ server.registerTool("annotate_dry_run", {
103
+ title: "Attribute dry_run updates to packageRules",
104
+ description: "Given a `dry_run` report and a config, attribute EACH proposed update to the `packageRules` that matched it — answering \"which of my rules produced these updates?\". Stateless and offline: run `dry_run` first, then pass its report here (inline `report` or `reportPath`, same shapes as `dry_run_diff`) plus a config source (`repoPath` or `configContent`). Each update is matched with Renovate's REAL matchers in a worker thread against the facts present in the report; fields the report doesn't carry (e.g. datasource, depType) are reported under each update's `unevaluatable` and aggregated in `fieldGaps` rather than silently treated as non-matches. `rulesNeverMatched` flags likely-dead rules. Deprecated matcher keys are warned about — run migrate_config first. Run dry_run for full-fidelity confirmation.",
105
+ inputSchema: {
106
+ report: reportRecord("A Renovate dry-run report — the raw report (`{ repositories }`) or a full `dry_run` summary (`{ report }`); the tool unwraps `report` automatically. Use instead of reportPath.").optional(),
107
+ reportPath: pathString("Absolute path to a JSON file containing the report (pair with `dry_run`'s reportOutputPath). Use instead of report.").optional(),
108
+ repoPath: pathString("Absolute path to the repository root; the tool locates and expands its renovate config to get the packageRules. Use instead of configContent.").optional(),
109
+ configContent: configRecord("Inline config object whose packageRules to attribute against. Use instead of repoPath.").optional(),
110
+ externalPresets: z
111
+ .boolean()
112
+ .optional()
113
+ .describe("When true, fetch external presets (github>, gitlab>) so their packageRules are included — same semantics as resolve_config. Default false."),
114
+ endpoint: endpointString("API base URL for github>/gitlab> fetches — same semantics as resolve_config.").optional(),
115
+ platform: z
116
+ .enum(["github", "gitlab"])
117
+ .optional()
118
+ .describe("Platform flavour of `endpoint` — same semantics as resolve_config."),
119
+ },
120
+ }, async ({ report, reportPath, repoPath, configContent, externalPresets, endpoint, platform }) => {
121
+ if (!report && !reportPath) {
122
+ return {
123
+ isError: true,
124
+ content: [{ type: "text", text: "Provide either report or reportPath (run dry_run first)." }],
125
+ };
126
+ }
127
+ if (!repoPath && !configContent) {
128
+ return {
129
+ isError: true,
130
+ content: [{ type: "text", text: "Provide a config source: either repoPath or configContent." }],
131
+ };
132
+ }
133
+ // Resolve the report.
134
+ let reportValue;
135
+ let reportSource;
136
+ if (reportPath) {
137
+ const res = await readReportPath(reportPath);
138
+ if (!res.ok)
139
+ return { isError: true, content: [{ type: "text", text: res.error }] };
140
+ reportValue = res.value;
141
+ reportSource = "reportPath";
142
+ }
143
+ else {
144
+ reportValue = report;
145
+ reportSource = "inline";
146
+ }
147
+ // Resolve the config's effective packageRules.
148
+ let source;
149
+ let sourcePath;
150
+ if (configContent) {
151
+ source = configContent;
152
+ }
153
+ else {
154
+ const located = await locateConfig(repoPath);
155
+ if (!located) {
156
+ return { content: [{ type: "text", text: `No Renovate configuration found in ${repoPath}.` }] };
157
+ }
158
+ source = located.config;
159
+ sourcePath = located.relPath;
160
+ }
161
+ const { resolved, warnings: presetWarnings, presetsUnresolved } = await resolveConfig(source, {
162
+ fetchExternal: externalPresets ?? false,
163
+ endpoint,
164
+ platform,
165
+ });
166
+ const packageRules = Array.isArray(resolved.packageRules)
167
+ ? resolved.packageRules.filter((r) => !!r && typeof r === "object" && !Array.isArray(r))
168
+ : [];
169
+ // Collect + dedup updates (a dep can appear in multiple branches).
170
+ const entriesRaw = collectUpdateEntries(extractReport(reportValue));
171
+ const seen = new Set();
172
+ const entries = [];
173
+ for (const e of entriesRaw) {
174
+ const key = identityKey(e.display);
175
+ if (seen.has(key))
176
+ continue;
177
+ seen.add(key);
178
+ entries.push(e);
179
+ }
180
+ const analysis = await analyzePackageRules(packageRules, entries.map((e) => e.context));
181
+ const matchedIndices = new Set();
182
+ const annotations = entries.map((entry, i) => {
183
+ const ruleAnalysis = analysis.contexts[i]?.rules ?? [];
184
+ const matchedRules = ruleAnalysis
185
+ .filter((r) => r.matched)
186
+ .map((r) => {
187
+ matchedIndices.add(r.index);
188
+ return {
189
+ index: r.index,
190
+ rule: r.rule,
191
+ matchedBy: r.matchedBy,
192
+ ...(r.contributedConfig ? { contributedConfig: r.contributedConfig } : {}),
193
+ };
194
+ });
195
+ const unevaluatable = ruleAnalysis.flatMap((r) => r.unevaluatable.map((u) => ({ ruleIndex: r.index, ...u })));
196
+ return { ...entry.display, matchedRules, unevaluatable };
197
+ });
198
+ const rulesNeverMatched = packageRules
199
+ .map((_, i) => i)
200
+ .filter((i) => !matchedIndices.has(i));
201
+ // fieldGaps: context fields referenced by the rules' matchers that no
202
+ // update in the report carried — the report shape didn't supply them.
203
+ const presentFields = new Set();
204
+ for (const e of entries)
205
+ for (const f of Object.keys(e.context))
206
+ presentFields.add(f);
207
+ const usedFields = new Set();
208
+ const metaByKey = new Map(Object.values(MATCHER_META).map((m) => [m.key, m]));
209
+ for (const rule of packageRules) {
210
+ for (const key of Object.keys(rule)) {
211
+ const meta = metaByKey.get(key);
212
+ if (meta)
213
+ for (const f of meta.requiredAnyOf)
214
+ usedFields.add(f);
215
+ }
216
+ }
217
+ const fieldGaps = [...usedFields].filter((f) => !presentFields.has(f)).sort();
218
+ const warnings = [...analysis.warnings, ...presetWarnings.map((w) => `${w.preset}: ${w.message}`)];
219
+ if (presetsUnresolved.length > 0) {
220
+ warnings.push(`${presetsUnresolved.length} preset(s) could not be expanded, so preset-provided packageRules may be missing: ${presetsUnresolved
221
+ .map((p) => p.preset)
222
+ .join(", ")}.`);
223
+ }
224
+ if (packageRules.length === 0)
225
+ warnings.push("The resolved config has no packageRules.");
226
+ if (entries.length === 0)
227
+ warnings.push("The report contained no proposed updates to annotate.");
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text",
232
+ text: JSON.stringify({
233
+ ...(sourcePath ? { configPath: sourcePath } : {}),
234
+ matchQuality: analysis.matchQuality,
235
+ disclaimer: analysis.matchQuality === "faithful" ? FAITHFUL_DISCLAIMER : PREVIEW_DISCLAIMER,
236
+ reportSource,
237
+ ruleCount: packageRules.length,
238
+ updateCount: entries.length,
239
+ annotations,
240
+ rulesNeverMatched,
241
+ fieldGaps,
242
+ warnings,
243
+ }, null, 2),
244
+ },
245
+ ],
246
+ };
247
+ });
248
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerTestPackageRules(server: McpServer): void;
@@ -0,0 +1,135 @@
1
+ import { z } from "zod";
2
+ import { locateConfig } from "../lib/configLocations.js";
3
+ import { resolveConfig } from "../lib/presetResolver.js";
4
+ import { analyzePackageRules } from "../lib/packageRulesAnalysis.js";
5
+ import { configRecord, endpointString, pathString } from "../lib/inputLimits.js";
6
+ const FAITHFUL_DISCLAIMER = "Rules are evaluated with Renovate's real matchers (run in a worker thread), so match decisions are faithful for the fields you supplied. Matchers needing post-lookup data you didn't supply (matchUpdateTypes, matchCurrentVersion, matchNewValue, matchCurrentAge) or the merge-confidence API (matchConfidence) are reported under `unevaluatable`, NOT as non-matches. matchJsonata results are advisory (the expression may read fields you didn't supply). Run dry_run for full-fidelity resolution.";
7
+ const PREVIEW_DISCLAIMER = "The faithful matcher worker was unavailable, so this used an approximate glob-only preview (matchPackageNames / matchDepNames / matchManagers / matchDatasources / matchFileNames / matchCategories only); rules using any other matcher are reported as unevaluatable. Run dry_run for authoritative output.";
8
+ const optString = (description) => z.string().max(2048).optional().describe(description);
9
+ const optStringArray = (description) => z.array(z.string().max(2048)).max(256).optional().describe(description);
10
+ export function registerTestPackageRules(server) {
11
+ server.registerTool("test_package_rules", {
12
+ title: "Test which packageRules match a dependency",
13
+ description: "Offline \"what-if\": given a hypothetical dependency context and a config, report which `packageRules` match (in order), which matcher decided each, and what each matched rule contributes. Answers \"why didn't my rule match?\". Rules are evaluated with Renovate's REAL matchers in a worker thread (faithful), classified against the fields you actually supplied — a matcher that needs a field you didn't pass is reported as `unevaluatable`, never silently treated as a non-match. Matchers needing post-lookup data (matchUpdateTypes, matchCurrentVersion, matchNewValue, matchCurrentAge) or the merge-confidence API (matchConfidence) are unevaluatable offline; matchJsonata is advisory. Pass either `repoPath` (reads + expands the repo's config) or `configContent` (an inline config); `externalPresets`/`endpoint`/`platform` opt into external preset expansion so `extends`-provided rules are included (same semantics as resolve_config). Deprecated matcher keys (matchPackagePatterns, …) are warned about — run migrate_config first. Run dry_run for full-fidelity confirmation.",
14
+ inputSchema: {
15
+ repoPath: pathString("Absolute path to the repository root. The tool locates and expands the repo's renovate config automatically.").optional(),
16
+ configContent: configRecord("Inline config object whose packageRules to test — use instead of repoPath.").optional(),
17
+ externalPresets: z
18
+ .boolean()
19
+ .optional()
20
+ .describe("When true, fetch external presets (github>, gitlab>) over HTTPS so their packageRules are included — same credentials/behaviour as resolve_config. Default false."),
21
+ endpoint: endpointString("API base URL for github>/gitlab> fetches — same semantics as resolve_config.").optional(),
22
+ platform: z
23
+ .enum(["github", "gitlab"])
24
+ .optional()
25
+ .describe("Platform flavour of `endpoint` — same semantics as resolve_config."),
26
+ // Synthetic dependency context. All optional; supply what you're testing.
27
+ depName: optString("Dependency name (matchDepNames; static)."),
28
+ packageName: optString("Fully-qualified package name (matchPackageNames; static)."),
29
+ datasource: optString("Datasource, e.g. npm/docker/github-releases (matchDatasources; static)."),
30
+ manager: optString("Manager, e.g. npm/dockerfile (matchManagers; static)."),
31
+ depType: optString("Dependency type, e.g. dependencies/devDependencies (matchDepTypes; static)."),
32
+ depTypes: optStringArray("Multiple dependency types (matchDepTypes; static)."),
33
+ currentValue: optString("Current version constraint from the file, e.g. ^1.2.0 (matchCurrentValue; static)."),
34
+ currentVersion: optString("Resolved current version, e.g. 1.2.3 (matchCurrentVersion; normally known only after a datasource lookup)."),
35
+ lockedVersion: optString("Locked version from a lockfile (matchCurrentVersion; post-lookup)."),
36
+ versioning: optString("Versioning scheme, e.g. npm/semver/docker (affects matchCurrentVersion)."),
37
+ packageFile: optString("Package file path (matchFileNames; static)."),
38
+ lockFiles: optStringArray("Lock file paths (matchFileNames; static)."),
39
+ categories: optStringArray("Manager categories, e.g. js/docker (matchCategories; static)."),
40
+ repository: optString("Repository slug, e.g. owner/repo (matchRepositories; static)."),
41
+ baseBranch: optString("Base branch name (matchBaseBranches; static)."),
42
+ registryUrls: optStringArray("Registry URLs (matchRegistryUrls; static)."),
43
+ sourceUrl: optString("Source repository URL (matchSourceUrls; static)."),
44
+ newValue: optString("Proposed new version constraint (matchNewValue; post-lookup)."),
45
+ updateType: optString("Update type: major/minor/patch/pin/digest/… (matchUpdateTypes; post-lookup)."),
46
+ isBump: z.boolean().optional().describe("Whether this is a range bump (matchUpdateTypes; post-lookup)."),
47
+ currentVersionTimestamp: optString("Release timestamp of the current version (matchCurrentAge; post-lookup)."),
48
+ mergeConfidenceLevel: optString("Merge-confidence level (matchConfidence; needs the merge-confidence API — unevaluatable offline)."),
49
+ },
50
+ }, async ({ repoPath, configContent, externalPresets, endpoint, platform, ...ctx }) => {
51
+ if (!repoPath && !configContent) {
52
+ return {
53
+ isError: true,
54
+ content: [{ type: "text", text: "Provide either repoPath or configContent." }],
55
+ };
56
+ }
57
+ let source;
58
+ let sourcePath;
59
+ if (configContent) {
60
+ source = configContent;
61
+ }
62
+ else {
63
+ const located = await locateConfig(repoPath);
64
+ if (!located) {
65
+ return {
66
+ content: [{ type: "text", text: `No Renovate configuration found in ${repoPath}.` }],
67
+ };
68
+ }
69
+ source = located.config;
70
+ sourcePath = located.relPath;
71
+ }
72
+ // Expand `extends` so preset-provided packageRules are included, then test
73
+ // against the effective set.
74
+ const { resolved, warnings: presetWarnings, presetsUnresolved } = await resolveConfig(source, {
75
+ fetchExternal: externalPresets ?? false,
76
+ endpoint,
77
+ platform,
78
+ });
79
+ const packageRules = Array.isArray(resolved.packageRules)
80
+ ? resolved.packageRules.filter((r) => !!r && typeof r === "object" && !Array.isArray(r))
81
+ : [];
82
+ // Build the synthetic dependency context from supplied fields only.
83
+ const context = {};
84
+ for (const [k, v] of Object.entries(ctx)) {
85
+ if (v !== undefined)
86
+ context[k] = v;
87
+ }
88
+ const analysis = await analyzePackageRules(packageRules, [context]);
89
+ const result = analysis.contexts[0] ?? { mergedConfig: {}, rules: [] };
90
+ const matchedRules = result.rules
91
+ .filter((r) => r.matched)
92
+ .map((r) => ({
93
+ index: r.index,
94
+ rule: r.rule,
95
+ matchedBy: r.matchedBy,
96
+ ...(r.contributedConfig ? { contributedConfig: r.contributedConfig } : {}),
97
+ }));
98
+ const unmatchedRules = result.rules
99
+ .filter((r) => !r.matched)
100
+ .map((r) => ({ index: r.index, rule: r.rule, decidedBy: r.decidedBy }));
101
+ const unevaluatable = result.rules.flatMap((r) => r.unevaluatable.map((u) => ({ ruleIndex: r.index, ...u })));
102
+ const effectiveConfig = { ...result.mergedConfig };
103
+ delete effectiveConfig.packageRules;
104
+ const warnings = [...analysis.warnings, ...presetWarnings.map((w) => `${w.preset}: ${w.message}`)];
105
+ if (presetsUnresolved.length > 0) {
106
+ warnings.push(`${presetsUnresolved.length} preset(s) could not be expanded, so preset-provided packageRules may be missing: ${presetsUnresolved
107
+ .map((p) => p.preset)
108
+ .join(", ")}. See resolve_config for details.`);
109
+ }
110
+ if (packageRules.length === 0) {
111
+ warnings.push("The resolved config has no packageRules to test.");
112
+ }
113
+ return {
114
+ content: [
115
+ {
116
+ type: "text",
117
+ text: JSON.stringify({
118
+ ...(sourcePath ? { path: sourcePath } : {}),
119
+ matchQuality: analysis.matchQuality,
120
+ disclaimer: analysis.matchQuality === "faithful"
121
+ ? FAITHFUL_DISCLAIMER
122
+ : PREVIEW_DISCLAIMER,
123
+ ruleCount: packageRules.length,
124
+ matchedRuleCount: matchedRules.length,
125
+ matchedRules,
126
+ unmatchedRules,
127
+ unevaluatable,
128
+ effectiveConfig,
129
+ warnings,
130
+ }, null, 2),
131
+ },
132
+ ],
133
+ };
134
+ });
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "renovate-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "MCP server for designing Renovate configurations interactively.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  "json5": "^2.2.3",
65
65
  "jsonata": "2.2.1",
66
66
  "jsonc-parser": "^3.3.1",
67
- "renovate": "^43.233.4",
67
+ "renovate": "^43.245.0",
68
68
  "smol-toml": "^1.6.1",
69
69
  "yaml": "^2.9.0",
70
70
  "zod": "^4.0.0"