@4da/mcp-server 5.0.3 → 6.0.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +667 -0
  2. package/README.md +66 -35
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -2
  6. package/dist/db.js +242 -36
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +31 -1
  51. package/dist/live/osv-scanner.js +119 -10
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -1
  158. package/package.json +27 -30
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -13,11 +13,11 @@ export const recordFeedbackTool = {
13
13
 
14
14
  Feedback actions:
15
15
  - "click": User clicked/opened the item
16
- - "save": User saved/bookmarked the item
16
+ - "save": User saved/bookmarked the item, or said it is relevant (also records a relevance label)
17
17
  - "dismiss": User dismissed the item
18
- - "mark_irrelevant": User marked item as not relevant
18
+ - "mark_irrelevant": User said the item is not relevant (also records a relevance label)
19
19
 
20
- This records what happened; it does not train content preferences (interaction counts only graduate scoring's bootstrap gate).`,
20
+ Only "save" and "mark_irrelevant" are relevance labels; they are what 4DA's accuracy measurement reads. Use them only when the user actually says whether the item matters to them. This does not train content preferences.`,
21
21
  inputSchema: {
22
22
  type: "object",
23
23
  properties: {
@@ -27,8 +27,7 @@ This records what happened; it does not train content preferences (interaction c
27
27
  },
28
28
  source_type: {
29
29
  type: "string",
30
- description: 'The source type: "hackernews", "arxiv", or "reddit"',
31
- enum: ["hackernews", "arxiv", "reddit"],
30
+ description: "Optional. The item's source type (e.g. \"hackernews\", \"crates_io\", \"lobsters\"); if given it must match the item.",
32
31
  },
33
32
  action: {
34
33
  type: "string",
@@ -36,7 +35,7 @@ This records what happened; it does not train content preferences (interaction c
36
35
  enum: ["click", "save", "dismiss", "mark_irrelevant"],
37
36
  },
38
37
  },
39
- required: ["item_id", "source_type", "action"],
38
+ required: ["item_id", "action"],
40
39
  },
41
40
  };
42
41
  const validActions = ["click", "save", "dismiss", "mark_irrelevant"];
@@ -44,10 +43,10 @@ const validActions = ["click", "save", "dismiss", "mark_irrelevant"];
44
43
  * Execute the record_feedback tool
45
44
  */
46
45
  export function executeRecordFeedback(db, params) {
47
- if (!params.item_id || !params.source_type || !params.action) {
46
+ if (!params.item_id || !params.action) {
48
47
  return {
49
48
  success: false,
50
- message: "item_id, source_type, and action are required",
49
+ message: "item_id and action are required",
51
50
  };
52
51
  }
53
52
  if (!validActions.includes(params.action)) {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Signal vocabulary for get_actionable_signals: the types, and the mapping
3
+ * from the desktop pipeline's stored priority tiers onto this tool's.
4
+ *
5
+ * This file used to hold a keyword classifier that labelled unstamped feed
6
+ * items. It was removed on 2026-10-02: two keywords such as "cve" and "patch"
7
+ * made an item a security alert, and any language name in the stack
8
+ * ("rust", "javascript") counted as a stack match, so russh and Angular CVEs
9
+ * no project used came out "high — affects your rust stack" and drove the
10
+ * briefing to human_only. The server no longer invents classifications;
11
+ * it reports the pipeline's, gated by the relevance judge.
12
+ */
13
+ export type SignalType = "security_alert" | "breaking_change" | "tool_discovery" | "tech_trend" | "learning" | "competitive_intel";
14
+ export type SignalPriority = "critical" | "high" | "medium" | "low";
15
+ /**
16
+ * The desktop pipeline stores its own notification tiers in
17
+ * `source_items.signal_priority` — critical, alert, advisory, watch — not this
18
+ * tool's critical/high/medium/low. The reader used to cast the stored string
19
+ * straight through, so 92 of the 93 stamped rows in the live corpus carried a
20
+ * priority no filter, sort, or briefing rule recognised (they sorted last and
21
+ * an "alert"-tier security alert could never reach the human_only rule).
22
+ * Mapped by tier order; an unrecognised value lands in the middle rather than
23
+ * being silently dropped or silently promoted.
24
+ */
25
+ export declare function normalizeStoredPriority(stored: string): SignalPriority;
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Signal vocabulary for get_actionable_signals: the types, and the mapping
4
+ * from the desktop pipeline's stored priority tiers onto this tool's.
5
+ *
6
+ * This file used to hold a keyword classifier that labelled unstamped feed
7
+ * items. It was removed on 2026-10-02: two keywords such as "cve" and "patch"
8
+ * made an item a security alert, and any language name in the stack
9
+ * ("rust", "javascript") counted as a stack match, so russh and Angular CVEs
10
+ * no project used came out "high — affects your rust stack" and drove the
11
+ * briefing to human_only. The server no longer invents classifications;
12
+ * it reports the pipeline's, gated by the relevance judge.
13
+ */
14
+ /**
15
+ * The desktop pipeline stores its own notification tiers in
16
+ * `source_items.signal_priority` — critical, alert, advisory, watch — not this
17
+ * tool's critical/high/medium/low. The reader used to cast the stored string
18
+ * straight through, so 92 of the 93 stamped rows in the live corpus carried a
19
+ * priority no filter, sort, or briefing rule recognised (they sorted last and
20
+ * an "alert"-tier security alert could never reach the human_only rule).
21
+ * Mapped by tier order; an unrecognised value lands in the middle rather than
22
+ * being silently dropped or silently promoted.
23
+ */
24
+ export function normalizeStoredPriority(stored) {
25
+ switch (stored.trim().toLowerCase()) {
26
+ case "critical":
27
+ return "critical";
28
+ case "alert":
29
+ case "high":
30
+ return "high";
31
+ case "advisory":
32
+ case "medium":
33
+ return "medium";
34
+ case "watch":
35
+ case "low":
36
+ return "low";
37
+ default:
38
+ return "medium";
39
+ }
40
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Call-site scan for `upgrade_impact`: which files in THIS project use the
3
+ * package, and which of its symbols they name.
4
+ *
5
+ * The symbols are what make a changelog actionable: "6 breaking changes" is
6
+ * noise until one of them says `InitOptions` and the project imports
7
+ * `InitOptions`. The scan is local-only (nothing leaves the machine) and
8
+ * bounded, because it runs inside a tool call: at most 6000 candidate files,
9
+ * none over 1 MB, and the usual generated / vendored directories skipped.
10
+ * Symlinks are not followed, so a link cycle cannot stall the walk.
11
+ *
12
+ * It is a lexical scan, not a type-checker: an import inside a comment
13
+ * counts, and a re-exported symbol used through a local barrel does not.
14
+ * Both errors are tolerable for prioritising a changelog — the entries are
15
+ * all still listed; this only decides which come first.
16
+ */
17
+ import type { UpgradeEcosystem } from "../live/upgrade-sources.js";
18
+ export interface CallSiteReport {
19
+ total_files: number;
20
+ files: Array<{
21
+ path: string;
22
+ matches: number;
23
+ }>;
24
+ symbols_used: string[];
25
+ /** Present when the walk stopped at the file cap: counts are then lower bounds. */
26
+ truncated?: string;
27
+ }
28
+ /** Count npm usages of `pkg` (or `pkg/sub`) in one source file and collect imported names. */
29
+ export declare function scanNpmSource(source: string, pkg: string, symbols: Set<string>): number;
30
+ /** Count Rust usages of a crate (`-` read as `_`) in one file and collect the item names it reaches. */
31
+ export declare function scanRustSource(source: string, crateName: string, symbols: Set<string>): number;
32
+ /**
33
+ * Bounded scan of `root` for usages of `pkg`: concurrent reads under a time
34
+ * budget. Measured 2026-10-02 on 4DA's tree (856 TS/JS, 721 Rust files on a
35
+ * nearly full disk): one-at-a-time synchronous reads took 20-26 s cold, so the
36
+ * reads now overlap, a file is regex-scanned only when it contains the name at
37
+ * all, and a scan that runs out of time says its counts are lower bounds.
38
+ */
39
+ export declare function scanCallSites(root: string, pkg: string, ecosystem: UpgradeEcosystem, opts?: {
40
+ timeBudgetMs?: number;
41
+ concurrency?: number;
42
+ }): Promise<CallSiteReport>;
43
+ /**
44
+ * Symbols (≥ 3 chars) that `text` names at a word boundary, case-sensitively.
45
+ * Shorter names (`fs`, `Ok`) would match ordinary prose, so they never flag.
46
+ * A plain lowercase word (`new`, `params`, `default` — Rust paths and
47
+ * CommonJS bindings yield plenty) counts only when the entry marks it as
48
+ * code: in backticks, called `name(`, or reached through `::` / `.`.
49
+ * Measured on the first live run: `TextEmbedding::new` put `new` in the
50
+ * symbol set, which would have flagged every "Add new model" entry.
51
+ */
52
+ export declare function matchSymbols(text: string, symbols: string[]): string[];
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Call-site scan for `upgrade_impact`: which files in THIS project use the
4
+ * package, and which of its symbols they name.
5
+ *
6
+ * The symbols are what make a changelog actionable: "6 breaking changes" is
7
+ * noise until one of them says `InitOptions` and the project imports
8
+ * `InitOptions`. The scan is local-only (nothing leaves the machine) and
9
+ * bounded, because it runs inside a tool call: at most 6000 candidate files,
10
+ * none over 1 MB, and the usual generated / vendored directories skipped.
11
+ * Symlinks are not followed, so a link cycle cannot stall the walk.
12
+ *
13
+ * It is a lexical scan, not a type-checker: an import inside a comment
14
+ * counts, and a re-exported symbol used through a local barrel does not.
15
+ * Both errors are tolerable for prioritising a changelog — the entries are
16
+ * all still listed; this only decides which come first.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { IgnoreRules } from "../gitignore.js";
21
+ const SKIP_DIRS = new Set([
22
+ "node_modules", "target", ".git", "dist", "build", "out", ".next",
23
+ "coverage", "vendor", ".claude", ".venv",
24
+ ]);
25
+ const NPM_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte"]);
26
+ const RUST_EXTS = new Set([".rs"]);
27
+ const MAX_FILES = 6000;
28
+ const MAX_FILE_BYTES = 1024 * 1024;
29
+ const MAX_REPORTED_FILES = 50;
30
+ const MAX_SYMBOLS = 100;
31
+ function escapeRegex(text) {
32
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
35
+ /** Names from an import clause: `Def`, `* as ns`, `{ a, b as c, type D }`. Imported names, not local aliases. */
36
+ function parseImportClause(clause, into) {
37
+ const braces = /\{([^}]*)\}/.exec(clause);
38
+ if (braces) {
39
+ for (const part of braces[1].split(",")) {
40
+ const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
41
+ if (IDENT.test(name) && name !== "default")
42
+ into.add(name);
43
+ }
44
+ }
45
+ const outside = clause.replace(/\{[^}]*\}/g, "").replace(/^\s*type\s+/, "");
46
+ const ns = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(outside);
47
+ if (ns)
48
+ into.add(ns[1]);
49
+ const def = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(outside);
50
+ if (def)
51
+ into.add(def[1]);
52
+ }
53
+ /** Count npm usages of `pkg` (or `pkg/sub`) in one source file and collect imported names. */
54
+ export function scanNpmSource(source, pkg, symbols) {
55
+ const spec = `${escapeRegex(pkg)}(?:/[^'"\`\\s]*)?`;
56
+ let matches = 0;
57
+ const fromRe = new RegExp(String.raw `\b(?:import|export)\s+([^;'"\`]*?)\s*from\s*['"]${spec}['"]`, "g");
58
+ for (const m of source.matchAll(fromRe)) {
59
+ matches++;
60
+ if (!/^\s*\*\s*$/.test(m[1]))
61
+ parseImportClause(m[1], symbols);
62
+ }
63
+ const bareRe = new RegExp(String.raw `\bimport\s*['"]${spec}['"]`, "g");
64
+ matches += [...source.matchAll(bareRe)].length;
65
+ const dynRe = new RegExp(String.raw `\bimport\s*\(\s*['"]${spec}['"]\s*\)`, "g");
66
+ matches += [...source.matchAll(dynRe)].length;
67
+ const reqRe = new RegExp(String.raw `(?:(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*)?\brequire\s*\(\s*['"]${spec}['"]\s*\)`, "g");
68
+ for (const m of source.matchAll(reqRe)) {
69
+ matches++;
70
+ const binding = m[1];
71
+ if (!binding)
72
+ continue;
73
+ if (binding.startsWith("{")) {
74
+ for (const part of binding.slice(1, -1).split(",")) {
75
+ const name = part.split(":")[0].trim();
76
+ if (IDENT.test(name))
77
+ symbols.add(name);
78
+ }
79
+ }
80
+ else
81
+ symbols.add(binding);
82
+ }
83
+ return matches;
84
+ }
85
+ /** Leaf names of a Rust `use` tree: `{A, b::{C, D as E}, self, *}` -> A, C, D. */
86
+ function useTreeLeaves(tree, into) {
87
+ const flat = tree.replace(/[{}]/g, ",");
88
+ for (const part of flat.split(",")) {
89
+ const pathPart = part.trim().split(/\s+as\s+/)[0].trim();
90
+ if (pathPart.endsWith("::"))
91
+ continue; // `models::{…}` — the group's members are the leaves
92
+ const leaf = pathPart.split("::").filter(Boolean).pop();
93
+ if (leaf && /^[A-Za-z_]\w*$/.test(leaf) && leaf !== "self" && leaf !== "super" && leaf !== "crate") {
94
+ into.add(leaf);
95
+ }
96
+ }
97
+ }
98
+ /** Count Rust usages of a crate (`-` read as `_`) in one file and collect the item names it reaches. */
99
+ export function scanRustSource(source, crateName, symbols) {
100
+ const ident = escapeRegex(crateName.replace(/-/g, "_"));
101
+ let matches = 0;
102
+ const useRe = new RegExp(String.raw `\buse\s+(?:::)?${ident}::([^;]+);`, "g");
103
+ const withoutUses = source.replace(useRe, (_all, tree) => {
104
+ matches++;
105
+ useTreeLeaves(tree, symbols);
106
+ return "";
107
+ });
108
+ const externRe = new RegExp(String.raw `\bextern\s+crate\s+${ident}\b`, "g");
109
+ matches += [...withoutUses.matchAll(externRe)].length;
110
+ // Qualified paths outside `use` lines; `crate::x::` / `self::x::` are local modules, hence the lookbehind.
111
+ const pathRe = new RegExp(String.raw `(?<![\w:])${ident}::((?:[A-Za-z_]\w*::)*[A-Za-z_]\w*)`, "g");
112
+ for (const m of withoutUses.matchAll(pathRe)) {
113
+ matches++;
114
+ for (const seg of m[1].split("::"))
115
+ symbols.add(seg);
116
+ }
117
+ return matches;
118
+ }
119
+ /** Source files under `root`, skipping installed code, build output, hidden and .gitignored directories. */
120
+ function listSources(root, exts, budget) {
121
+ const files = [];
122
+ const ignore = new IgnoreRules(root);
123
+ const stack = [[root, ""]];
124
+ while (stack.length > 0 && budget.left > 0) {
125
+ const [dir, rel] = stack.pop();
126
+ ignore.load(rel);
127
+ let entries;
128
+ try {
129
+ entries = fs.readdirSync(dir, { withFileTypes: true });
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ for (const entry of entries) {
135
+ if (entry.isSymbolicLink())
136
+ continue;
137
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
138
+ if (entry.isDirectory()) {
139
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || ignore.ignores(childRel, true))
140
+ continue;
141
+ stack.push([path.join(dir, entry.name), childRel]);
142
+ }
143
+ else if (entry.isFile() && exts.has(path.extname(entry.name).toLowerCase())) {
144
+ if (budget.left-- <= 0)
145
+ break;
146
+ files.push(path.join(dir, entry.name));
147
+ }
148
+ }
149
+ }
150
+ return files;
151
+ }
152
+ /**
153
+ * Bounded scan of `root` for usages of `pkg`: concurrent reads under a time
154
+ * budget. Measured 2026-10-02 on 4DA's tree (856 TS/JS, 721 Rust files on a
155
+ * nearly full disk): one-at-a-time synchronous reads took 20-26 s cold, so the
156
+ * reads now overlap, a file is regex-scanned only when it contains the name at
157
+ * all, and a scan that runs out of time says its counts are lower bounds.
158
+ */
159
+ export async function scanCallSites(root, pkg, ecosystem, opts = {}) {
160
+ const exts = ecosystem === "npm" ? NPM_EXTS : RUST_EXTS;
161
+ const budget = { left: MAX_FILES };
162
+ const symbols = new Set();
163
+ const hits = [];
164
+ const needle = ecosystem === "npm" ? pkg : pkg.replace(/-/g, "_");
165
+ const deadline = Date.now() + (opts.timeBudgetMs ?? CALLSITE_TIME_BUDGET_MS);
166
+ const files = listSources(root, exts, budget);
167
+ let next = 0;
168
+ let timedOut = false;
169
+ const worker = async () => {
170
+ while (next < files.length) {
171
+ if (Date.now() > deadline) {
172
+ timedOut = true;
173
+ return;
174
+ }
175
+ const file = files[next++];
176
+ let source;
177
+ try {
178
+ const stat = await fs.promises.stat(file);
179
+ if (stat.size > MAX_FILE_BYTES)
180
+ continue;
181
+ source = await fs.promises.readFile(file, "utf8");
182
+ }
183
+ catch {
184
+ continue;
185
+ }
186
+ if (!source.includes(needle))
187
+ continue;
188
+ const matches = ecosystem === "npm" ? scanNpmSource(source, pkg, symbols) : scanRustSource(source, pkg, symbols);
189
+ if (matches > 0)
190
+ hits.push({ path: path.relative(root, file).split(path.sep).join("/"), matches });
191
+ }
192
+ };
193
+ await Promise.all(Array.from({ length: Math.min(opts.concurrency ?? 32, files.length) }, worker));
194
+ hits.sort((a, b) => b.matches - a.matches || a.path.localeCompare(b.path));
195
+ const report = {
196
+ total_files: hits.length,
197
+ files: hits.slice(0, MAX_REPORTED_FILES),
198
+ symbols_used: [...symbols].sort().slice(0, MAX_SYMBOLS),
199
+ };
200
+ if (budget.left <= 0)
201
+ report.truncated = `stopped after ${MAX_FILES} source files; counts are lower bounds`;
202
+ else if (timedOut)
203
+ report.truncated = `stopped after ${Math.round((opts.timeBudgetMs ?? CALLSITE_TIME_BUDGET_MS) / 1000)} s with ${files.length - next} of ${files.length} files unread; counts are lower bounds`;
204
+ return report;
205
+ }
206
+ const CALLSITE_TIME_BUDGET_MS = 10_000;
207
+ /**
208
+ * Symbols (≥ 3 chars) that `text` names at a word boundary, case-sensitively.
209
+ * Shorter names (`fs`, `Ok`) would match ordinary prose, so they never flag.
210
+ * A plain lowercase word (`new`, `params`, `default` — Rust paths and
211
+ * CommonJS bindings yield plenty) counts only when the entry marks it as
212
+ * code: in backticks, called `name(`, or reached through `::` / `.`.
213
+ * Measured on the first live run: `TextEmbedding::new` put `new` in the
214
+ * symbol set, which would have flagged every "Add new model" entry.
215
+ */
216
+ export function matchSymbols(text, symbols) {
217
+ return symbols.filter((s) => {
218
+ if (s.length < 3)
219
+ return false;
220
+ const e = escapeRegex(s);
221
+ const pattern = /^[a-z]+$/.test(s)
222
+ ? String.raw `\x60${e}(?:\(\))?\x60|(?<![\w$])${e}\(|(?:::|\.)${e}(?![\w$])|(?<![\w$])${e}::`
223
+ : String.raw `(?<![\w$])${e}(?![\w$])`;
224
+ return new RegExp(pattern).test(text);
225
+ });
226
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Breaking changes that live in string literals: route and pattern syntax.
3
+ *
4
+ * Symbol matching cannot see these. axum 0.8 changed path parameters from
5
+ * `/:single` and `/*many` to `/{single}` and `/{*many}`; a project's code
6
+ * imports `Router`, and the old syntax sits only in strings like
7
+ * `.route("/users/:id", ...)`. express 5 (path-to-regexp 8) and react-router
8
+ * changed route syntax the same way.
9
+ *
10
+ * So: take the backticked syntax fragments on the "from" side of a breaking
11
+ * entry ("changed from `/:single` ... to `/{single}`"), turn each into a shape
12
+ * (identifiers become placeholders, punctuation stays literal: `/:\w+`), and
13
+ * look for that shape inside string literals of the files that import the
14
+ * package. Only fragments that are mostly syntax qualify; a code path like
15
+ * `Router::route` or `res.json()` is symbol matching's job.
16
+ */
17
+ import type { ReportSection } from "./upgrade-impact-report.js";
18
+ export interface LiteralHit {
19
+ file: string;
20
+ line: number;
21
+ literal: string;
22
+ }
23
+ /**
24
+ * Shapes of the syntax an entry says is going away. Only the text before
25
+ * " to " when the entry reads "from X to Y", so the new syntax (which a
26
+ * migrated project already uses) is never reported as needing a change.
27
+ */
28
+ export declare function oldSyntaxShapes(entryText: string): RegExp[];
29
+ /** String literals of one source file, with their line numbers. */
30
+ export declare function stringLiterals(source: string): Array<{
31
+ text: string;
32
+ line: number;
33
+ }>;
34
+ /**
35
+ * Flag breaking/deprecation entries whose old syntax appears in a string
36
+ * literal of a file that imports the package. Mutates the entries (adds
37
+ * `touches_your_code` and `matched_literals`) and returns how many breaking
38
+ * entries became touching, plus up to five example literals for the summary.
39
+ */
40
+ export declare function flagOldSyntax(sections: ReportSection[], files: Array<{
41
+ path: string;
42
+ }>, root: string | null): Promise<{
43
+ newlyTouching: number;
44
+ examples: string[];
45
+ }>;
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Breaking changes that live in string literals: route and pattern syntax.
4
+ *
5
+ * Symbol matching cannot see these. axum 0.8 changed path parameters from
6
+ * `/:single` and `/*many` to `/{single}` and `/{*many}`; a project's code
7
+ * imports `Router`, and the old syntax sits only in strings like
8
+ * `.route("/users/:id", ...)`. express 5 (path-to-regexp 8) and react-router
9
+ * changed route syntax the same way.
10
+ *
11
+ * So: take the backticked syntax fragments on the "from" side of a breaking
12
+ * entry ("changed from `/:single` ... to `/{single}`"), turn each into a shape
13
+ * (identifiers become placeholders, punctuation stays literal: `/:\w+`), and
14
+ * look for that shape inside string literals of the files that import the
15
+ * package. Only fragments that are mostly syntax qualify; a code path like
16
+ * `Router::route` or `res.json()` is symbol matching's job.
17
+ */
18
+ import { promises as fs } from "node:fs";
19
+ import path from "node:path";
20
+ /** Punctuation that makes a fragment syntax rather than a name. */
21
+ const SYNTAX_CHARS = /[/:*{}?$%@#<>]/g;
22
+ const WORD = /[A-Za-z_][\w-]*/g;
23
+ const MAX_FILES = 300;
24
+ const MAX_FILE_BYTES = 512 * 1024;
25
+ const MAX_HITS_PER_ENTRY = 5;
26
+ function escapeRegex(text) {
27
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ }
29
+ /**
30
+ * Shapes of the syntax an entry says is going away. Only the text before
31
+ * " to " when the entry reads "from X to Y", so the new syntax (which a
32
+ * migrated project already uses) is never reported as needing a change.
33
+ */
34
+ export function oldSyntaxShapes(entryText) {
35
+ const fromTo = /\bfrom\b([\s\S]*?)\bto\b/i.exec(entryText);
36
+ const scope = fromTo ? fromTo[1] : entryText;
37
+ const shapes = [];
38
+ for (const [, fragment] of scope.matchAll(/`([^`\s]{2,40})`/g)) {
39
+ // A path or call (`Router::route`, `res.json()`, `a.b`) is a symbol, not syntax.
40
+ if (/^[\w$.:]+(?:\(\))?$/.test(fragment) || fragment.includes("("))
41
+ continue;
42
+ const syntax = fragment.match(SYNTAX_CHARS) ?? [];
43
+ const words = fragment.match(WORD) ?? [];
44
+ if (syntax.length < 2 || words.length === 0)
45
+ continue;
46
+ let pattern = "";
47
+ let last = 0;
48
+ for (const m of fragment.matchAll(WORD)) {
49
+ pattern += escapeRegex(fragment.slice(last, m.index)) + "[A-Za-z_][\\w-]*";
50
+ last = (m.index ?? 0) + m[0].length;
51
+ }
52
+ pattern += escapeRegex(fragment.slice(last));
53
+ shapes.push(new RegExp(pattern));
54
+ }
55
+ return shapes;
56
+ }
57
+ const STRING_LITERAL = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/g;
58
+ /** String literals of one source file, with their line numbers. */
59
+ export function stringLiterals(source) {
60
+ const out = [];
61
+ for (const m of source.matchAll(STRING_LITERAL)) {
62
+ const start = m.index ?? 0;
63
+ out.push({ text: m[0].slice(1, -1), line: source.slice(0, start).split("\n").length });
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Flag breaking/deprecation entries whose old syntax appears in a string
69
+ * literal of a file that imports the package. Mutates the entries (adds
70
+ * `touches_your_code` and `matched_literals`) and returns how many breaking
71
+ * entries became touching, plus up to five example literals for the summary.
72
+ */
73
+ export async function flagOldSyntax(sections, files, root) {
74
+ const candidates = sections
75
+ .flatMap((s) => s.entries)
76
+ .filter((e) => e.kind === "breaking" || e.kind === "deprecation")
77
+ .map((entry) => ({ entry, shapes: oldSyntaxShapes(entry.text) }))
78
+ .filter((c) => c.shapes.length > 0);
79
+ if (!root || candidates.length === 0 || files.length === 0)
80
+ return { newlyTouching: 0, examples: [] };
81
+ const literals = [];
82
+ for (const file of files.slice(0, MAX_FILES)) {
83
+ const abs = path.resolve(root, file.path);
84
+ try {
85
+ const stat = await fs.stat(abs);
86
+ if (stat.size > MAX_FILE_BYTES)
87
+ continue;
88
+ for (const lit of stringLiterals(await fs.readFile(abs, "utf8"))) {
89
+ // Routes and patterns have no whitespace; code and prose inside strings do (a test
90
+ // fixture in 4DA's watcher.rs holding Rust source matched `/*many` on the first live run).
91
+ if (lit.text.length <= 200 && !/\s|\\[nrt]/.test(lit.text))
92
+ literals.push({ file: file.path, ...lit });
93
+ }
94
+ }
95
+ catch {
96
+ // A file that vanished since the scan is simply not searched.
97
+ }
98
+ }
99
+ let newlyTouching = 0;
100
+ const examples = new Set();
101
+ for (const { entry, shapes } of candidates) {
102
+ const hits = [];
103
+ for (const lit of literals) {
104
+ if (hits.length >= MAX_HITS_PER_ENTRY)
105
+ break;
106
+ if (shapes.some((s) => s.test(lit.text)))
107
+ hits.push({ file: lit.file, line: lit.line, literal: lit.text.slice(0, 80) });
108
+ }
109
+ if (hits.length === 0)
110
+ continue;
111
+ if (entry.kind === "breaking" && !entry.touches_your_code)
112
+ newlyTouching++;
113
+ entry.touches_your_code = true;
114
+ entry.matched_literals = hits;
115
+ hits.forEach((h) => examples.size < 5 && examples.add(`"${h.literal}"`));
116
+ }
117
+ return { newlyTouching, examples: [...examples] };
118
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Output shaping for `upgrade_impact`: cross-referencing changelog entries
3
+ * with the project's symbols, concise vs detailed trimming, and the one-line
4
+ * summary an agent reads first.
5
+ *
6
+ * Concise is the default because a multi-major upgrade (vite 6 -> 7 crosses
7
+ * dozens of releases) produces hundreds of "fix typo"-class entries that
8
+ * would bury the five that matter. Concise keeps EVERY breaking, deprecation
9
+ * and security entry; of the plain changes it keeps up to 20 per version that
10
+ * sit under an API or neutral heading ("Changed", "Methods", "Types", or none)
11
+ * and up to 3 under an additive one ("Added", "Fixed", "Docs"), and states how
12
+ * many it left out — omission is always visible, never silent.
13
+ *
14
+ * The neutral allowance is where the classifier's misses live: on the
15
+ * 2026-10-03 panel, 11 of 15 breaking changes it called plain sat under
16
+ * actix-web 4's "Functions" / "Methods" / "Types" headings, and three-per-version
17
+ * hid them from the agent entirely.
18
+ */
19
+ import { type EntryKind } from "../live/changelog-classify.js";
20
+ import type { ChangelogSection } from "../live/changelog.js";
21
+ export type ResponseFormat = "concise" | "detailed";
22
+ export interface ReportEntry {
23
+ kind: EntryKind;
24
+ text: string;
25
+ /** The changelog heading or parent bullet the entry sits under ("Removed", "Breaking Changes"). */
26
+ under?: string;
27
+ touches_your_code?: boolean;
28
+ matched_symbols?: string[];
29
+ /** String literals in your code that use the syntax this entry retires (route patterns and the like). */
30
+ matched_literals?: Array<{
31
+ file: string;
32
+ line: number;
33
+ literal: string;
34
+ }>;
35
+ }
36
+ export interface ReportSection {
37
+ version: string;
38
+ date: string | null;
39
+ entries: ReportEntry[];
40
+ /** Concise mode only: how many plain `change` entries were left out. */
41
+ omitted_changes?: number;
42
+ }
43
+ export interface ShapedChangelog {
44
+ sections: ReportSection[];
45
+ breaking: number;
46
+ deprecations: number;
47
+ security: number;
48
+ /** Breaking entries naming a symbol this project uses (deprecations are flagged per entry but not counted here). */
49
+ touching: number;
50
+ touchingSymbols: string[];
51
+ truncated?: string;
52
+ /** Entries left out because an earlier (newer) section already listed the same change. */
53
+ duplicates?: number;
54
+ }
55
+ /** Flag, order and trim changelog sections. Counts always cover every entry, trimmed or not. */
56
+ export declare function shapeChangelog(sections: ChangelogSection[], symbols: string[], format: ResponseFormat): ShapedChangelog;
57
+ export type UpgradeType = "patch" | "minor" | "major" | "prerelease" | "unknown";
58
+ /**
59
+ * Upgrade class by semver. A 0.x minor bump is reported as "major": under
60
+ * caret rules (npm and Cargo alike) 0.32 -> 0.37 is as breaking as 1 -> 2.
61
+ */
62
+ export declare function upgradeType(from: string, to: string): UpgradeType;
63
+ /** Major versions crossed (0.x minors count as majors, matching `upgradeType`). */
64
+ export declare function majorsCrossed(from: string, to: string): number;
65
+ /** Up to `count` published versions nearest to `wanted` by precedence, for "version not found" errors. */
66
+ export declare function nearestVersions(published: string[], requested: string, count?: number): string[];
67
+ export interface SummaryInput {
68
+ pkg: string;
69
+ from: string;
70
+ to: string;
71
+ releases: number;
72
+ breaking: number;
73
+ touching: number;
74
+ touchingSymbols: string[];
75
+ /** missing = no changelog file; no_entries = a file with no section for these releases; partial = some of them. */
76
+ changelog: "missing" | "no_entries" | "partial" | "complete";
77
+ advisoriesFixed: number | null;
78
+ }
79
+ /** The one sentence a human or agent reads first. */
80
+ export declare function summarize(input: SummaryInput): string;