@4da/mcp-server 5.0.4 → 6.0.1
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/CHANGELOG.md +698 -0
- package/README.md +70 -39
- package/dist/build-staleness.d.ts +35 -0
- package/dist/build-staleness.js +95 -0
- package/dist/db.d.ts +53 -58
- package/dist/db.js +226 -299
- package/dist/doctor.js +27 -7
- package/dist/embeddings.js +7 -1
- package/dist/freshness-markers.d.ts +34 -0
- package/dist/freshness-markers.js +74 -0
- package/dist/gitignore.d.ts +37 -0
- package/dist/gitignore.js +130 -0
- package/dist/index.js +127 -44
- package/dist/live/bun-lockfile.d.ts +26 -0
- package/dist/live/bun-lockfile.js +123 -0
- package/dist/live/cargo-platform.d.ts +38 -0
- package/dist/live/cargo-platform.js +178 -0
- package/dist/live/changelog-classify.d.ts +41 -0
- package/dist/live/changelog-classify.js +207 -0
- package/dist/live/changelog.d.ts +64 -0
- package/dist/live/changelog.js +318 -0
- package/dist/live/crates-registry.js +3 -1
- package/dist/live/crates-versions.d.ts +47 -0
- package/dist/live/crates-versions.js +201 -0
- package/dist/live/dependency-set.d.ts +13 -0
- package/dist/live/dependency-set.js +95 -0
- package/dist/live/dev-scope.d.ts +32 -0
- package/dist/live/dev-scope.js +102 -0
- package/dist/live/file-signature.d.ts +18 -0
- package/dist/live/file-signature.js +43 -0
- package/dist/live/go-registry.js +3 -1
- package/dist/live/hn-fetcher.js +9 -3
- package/dist/live/index.d.ts +105 -2
- package/dist/live/index.js +266 -139
- package/dist/live/install-state.d.ts +43 -0
- package/dist/live/install-state.js +150 -0
- package/dist/live/js-lockfile-readers.d.ts +46 -0
- package/dist/live/js-lockfile-readers.js +181 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
- package/dist/live/lockfile-parsers-pygo.js +274 -0
- package/dist/live/lockfile-parsers.d.ts +69 -0
- package/dist/live/lockfile-parsers.js +311 -0
- package/dist/live/lockfile-types.d.ts +34 -0
- package/dist/live/lockfile-types.js +42 -0
- package/dist/live/maintenance.d.ts +33 -0
- package/dist/live/maintenance.js +37 -0
- package/dist/live/npm-packument.d.ts +47 -0
- package/dist/live/npm-packument.js +150 -0
- package/dist/live/npm-registry.js +67 -53
- package/dist/live/osv-scanner.d.ts +12 -1
- package/dist/live/osv-scanner.js +50 -15
- package/dist/live/package-archive.d.ts +49 -0
- package/dist/live/package-archive.js +186 -0
- package/dist/live/pypi-registry.js +3 -1
- package/dist/live/registry-health.d.ts +16 -0
- package/dist/live/registry-health.js +74 -0
- package/dist/live/release-metadata.d.ts +67 -0
- package/dist/live/release-metadata.js +16 -0
- package/dist/live/resolution.d.ts +43 -0
- package/dist/live/resolution.js +62 -0
- package/dist/live/semver-precedence.d.ts +30 -0
- package/dist/live/semver-precedence.js +83 -0
- package/dist/live/severity-scope.d.ts +57 -0
- package/dist/live/severity-scope.js +89 -0
- package/dist/live/types.d.ts +83 -0
- package/dist/live/upgrade-sources.d.ts +71 -0
- package/dist/live/upgrade-sources.js +198 -0
- package/dist/live/version-compare.d.ts +39 -0
- package/dist/live/version-compare.js +130 -0
- package/dist/live/version-resolver.d.ts +14 -1
- package/dist/live/version-resolver.js +93 -303
- package/dist/native-bindings.d.ts +19 -0
- package/dist/native-bindings.js +39 -0
- package/dist/project-manifests.d.ts +40 -0
- package/dist/project-manifests.js +193 -0
- package/dist/project-scanner.js +38 -48
- package/dist/project-tree.d.ts +41 -0
- package/dist/project-tree.js +110 -0
- package/dist/prompts.d.ts +28 -0
- package/dist/prompts.js +54 -0
- package/dist/schema-registry.d.ts +41 -29
- package/dist/schema-registry.js +98 -105
- package/dist/server-instructions.d.ts +11 -0
- package/dist/server-instructions.js +36 -0
- package/dist/tool-args.d.ts +28 -0
- package/dist/tool-args.js +93 -0
- package/dist/tool-dispatch.d.ts +1 -1
- package/dist/tool-dispatch.js +42 -8
- package/dist/tools/app-plan.d.ts +92 -0
- package/dist/tools/app-plan.js +155 -0
- package/dist/tools/briefing-stored-advisories.d.ts +20 -0
- package/dist/tools/briefing-stored-advisories.js +58 -0
- package/dist/tools/briefing-task-scope.d.ts +73 -0
- package/dist/tools/briefing-task-scope.js +197 -0
- package/dist/tools/briefing-wisdom.d.ts +31 -0
- package/dist/tools/briefing-wisdom.js +148 -0
- package/dist/tools/decision-enforcement.d.ts +0 -5
- package/dist/tools/decision-enforcement.js +0 -4
- package/dist/tools/decision-memory.js +18 -66
- package/dist/tools/dependency-check-signals.d.ts +74 -0
- package/dist/tools/dependency-check-signals.js +264 -0
- package/dist/tools/dependency-check.d.ts +98 -0
- package/dist/tools/dependency-check.js +295 -0
- package/dist/tools/dependency-health.js +74 -21
- package/dist/tools/developer-dna.d.ts +3 -0
- package/dist/tools/developer-dna.js +16 -15
- package/dist/tools/ecosystem-pulse.d.ts +6 -2
- package/dist/tools/ecosystem-pulse.js +53 -14
- package/dist/tools/get-actionable-signals.d.ts +26 -4
- package/dist/tools/get-actionable-signals.js +214 -251
- package/dist/tools/get-context.d.ts +2 -0
- package/dist/tools/get-context.js +9 -1
- package/dist/tools/get-relevant-content.d.ts +3 -1
- package/dist/tools/get-relevant-content.js +34 -3
- package/dist/tools/index.d.ts +4 -2
- package/dist/tools/index.js +4 -2
- package/dist/tools/install-drift-notes.d.ts +51 -0
- package/dist/tools/install-drift-notes.js +172 -0
- package/dist/tools/knowledge-gap-citations.d.ts +55 -0
- package/dist/tools/knowledge-gap-citations.js +132 -0
- package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
- package/dist/tools/knowledge-gap-exposure.js +160 -0
- package/dist/tools/knowledge-gap-grading.d.ts +91 -0
- package/dist/tools/knowledge-gap-grading.js +157 -0
- package/dist/tools/knowledge-gap-installs.d.ts +65 -0
- package/dist/tools/knowledge-gap-installs.js +134 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
- package/dist/tools/knowledge-gap-ranges.js +146 -0
- package/dist/tools/knowledge-gap-scope.d.ts +63 -0
- package/dist/tools/knowledge-gap-scope.js +118 -0
- package/dist/tools/knowledge-gaps.d.ts +12 -3
- package/dist/tools/knowledge-gaps.js +172 -121
- package/dist/tools/package-presence.d.ts +24 -0
- package/dist/tools/package-presence.js +35 -0
- package/dist/tools/record-feedback.d.ts +0 -1
- package/dist/tools/record-feedback.js +7 -8
- package/dist/tools/signal-classifier.d.ts +25 -0
- package/dist/tools/signal-classifier.js +40 -0
- package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
- package/dist/tools/upgrade-impact-callsites.js +226 -0
- package/dist/tools/upgrade-impact-literals.d.ts +45 -0
- package/dist/tools/upgrade-impact-literals.js +118 -0
- package/dist/tools/upgrade-impact-report.d.ts +80 -0
- package/dist/tools/upgrade-impact-report.js +178 -0
- package/dist/tools/upgrade-impact.d.ts +84 -0
- package/dist/tools/upgrade-impact.js +305 -0
- package/dist/tools/upgrade-planner.d.ts +41 -8
- package/dist/tools/upgrade-planner.js +201 -53
- package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
- package/dist/tools/vulnerability-scan-concise.js +97 -0
- package/dist/tools/vulnerability-scan-format.d.ts +137 -0
- package/dist/tools/vulnerability-scan-format.js +276 -0
- package/dist/tools/vulnerability-scan.d.ts +338 -18
- package/dist/tools/vulnerability-scan.js +84 -125
- package/dist/tools/what-should-i-know.d.ts +86 -19
- package/dist/tools/what-should-i-know.js +277 -283
- package/dist/types.d.ts +4 -136
- package/package.json +35 -35
- package/dist/schemas/agent-memory.json +0 -49
- package/dist/schemas/decision-enforcement.json +0 -22
- package/dist/schemas/decision-memory.json +0 -54
- package/dist/schemas/dependency-health.json +0 -27
- package/dist/schemas/developer-dna.json +0 -219
- package/dist/schemas/ecosystem-pulse.json +0 -17
- package/dist/schemas/get-actionable-signals.json +0 -29
- package/dist/schemas/get-context.json +0 -17
- package/dist/schemas/get-relevant-content.json +0 -26
- package/dist/schemas/knowledge-gaps.json +0 -14
- package/dist/schemas/record-feedback.json +0 -24
- package/dist/schemas/upgrade-planner.json +0 -22
- package/dist/schemas/vulnerability-scan.json +0 -26
- package/dist/schemas/what-should-i-know.json +0 -19
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* upgrade_planner tool
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* One brain (AD-049): when the 4DA app's database holds its persisted Upgrade
|
|
6
|
+
* Plan (current schema), the tool returns that plan's machine-readable work
|
|
7
|
+
* order (`provenance.mode: "app_plan"`, flagged `stale` past its horizon) —
|
|
8
|
+
* see ./app-plan.ts. Only without one does it run the standalone heuristic
|
|
9
|
+
* below: version freshness, vulnerability data and deprecation status from
|
|
10
|
+
* the local lockfiles (`provenance.mode: "standalone_heuristic"`).
|
|
8
11
|
*
|
|
9
12
|
* Honesty contract (Phase 0 hardening):
|
|
10
13
|
* - Transitive CVEs are surfaced as `waiting_on_upstream` steps instead of
|
|
@@ -14,15 +17,26 @@
|
|
|
14
17
|
* standalone plan, not the 4DA app's cross-project, version-confirmed plan.
|
|
15
18
|
* - `projectPath` reports the directory set the dependencies were actually
|
|
16
19
|
* resolved from, not whatever `process.cwd()` happens to be.
|
|
17
|
-
* - When no vulnerability scan has run, the
|
|
18
|
-
*
|
|
20
|
+
* - When no vulnerability scan has run, the planner runs one (bounded); if it
|
|
21
|
+
* cannot finish, the plan says it is CVE-blind instead of ranking silently.
|
|
22
|
+
* - When node_modules holds a different version than the lockfile, the row
|
|
23
|
+
* says so (`installedVersion`) and a reinstall is the step when nothing
|
|
24
|
+
* else about the lockfile's version needs changing.
|
|
19
25
|
*/
|
|
20
|
-
import {
|
|
26
|
+
import { isMaintenanceNotice } from "../live/maintenance.js";
|
|
27
|
+
import { computeSemverDistance } from "../live/semver-utils.js";
|
|
28
|
+
import { compareVersions, maxVersion, namesPackage } from "../live/version-compare.js";
|
|
29
|
+
import { formatAppPlan, readAppPlan } from "./app-plan.js";
|
|
30
|
+
import { dirsLabel, driftFor } from "./install-drift-notes.js";
|
|
31
|
+
import { emptyAnswerNote } from "./package-presence.js";
|
|
32
|
+
import { relativeDir } from "./vulnerability-scan-format.js";
|
|
33
|
+
/** How long a plan waits for its own OSV scan before answering CVE-blind (the scan keeps running). */
|
|
34
|
+
const PLANNER_SCAN_TIMEOUT_MS = 60_000;
|
|
21
35
|
const PROVENANCE_NOTE = "Point-in-time heuristic from local lockfiles plus live registry/OSV lookups. " +
|
|
22
|
-
"Not the 4DA desktop app's cross-project, version-confirmed plan.";
|
|
36
|
+
"Not the 4DA desktop app's cross-project, version-confirmed plan (none was available).";
|
|
23
37
|
export const upgradePlannerTool = {
|
|
24
38
|
name: "upgrade_planner",
|
|
25
|
-
description: "Ranked
|
|
39
|
+
description: "Ranked dependency upgrade plan — call before upgrading, adding, or auditing any dependency to pick the safest order. When the 4DA desktop app has computed its plan, returns that plan's work order (provenance app_plan): per package the ecosystem, each installed version with its minimum clean target, upgrade type (patch/minor/major), the projects that hold it (direct/dev) and the mechanism (manifest_bump, lockfile_or_parent_update, mixed, no_fix); flagged stale past its freshness horizon. Otherwise falls back to a standalone plan from local lockfiles (provenance standalone_heuristic): the smallest version that fixes each vulnerability (direct and transitive), deprecation and version distance; it runs the OSV scan itself when none has run. `package` narrows either plan to one package. Privacy: the fallback may query public registries and OSV with package names and versions; it never sends source code.",
|
|
26
40
|
inputSchema: {
|
|
27
41
|
type: "object",
|
|
28
42
|
properties: {
|
|
@@ -39,6 +53,10 @@ export const upgradePlannerTool = {
|
|
|
39
53
|
enum: ["all", "low", "medium", "high", "critical"],
|
|
40
54
|
description: "Only show upgrades at or above this risk level. Default: all",
|
|
41
55
|
},
|
|
56
|
+
package: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "Plan for this one package only (exact name, any ecosystem). Default: every dependency.",
|
|
59
|
+
},
|
|
42
60
|
},
|
|
43
61
|
},
|
|
44
62
|
};
|
|
@@ -50,7 +68,26 @@ function severityToRisk(severities) {
|
|
|
50
68
|
return "high";
|
|
51
69
|
return "medium";
|
|
52
70
|
}
|
|
53
|
-
|
|
71
|
+
/** Distance between the installed and target versions (patch/minor/major), or unknown. */
|
|
72
|
+
function upgradeTypeBetween(current, target) {
|
|
73
|
+
if (!current || !target)
|
|
74
|
+
return "unknown";
|
|
75
|
+
const distance = computeSemverDistance(current, target);
|
|
76
|
+
return !distance || distance.label === "up-to-date" ? "unknown" : distance.label;
|
|
77
|
+
}
|
|
78
|
+
function higherRisk(a, b) {
|
|
79
|
+
return RISK_LEVELS[b] > RISK_LEVELS[a] ? b : a;
|
|
80
|
+
}
|
|
81
|
+
export async function executeUpgradePlanner(db, params, liveIntel) {
|
|
82
|
+
// One brain: the app's plan wins whenever there is one, stale or not (a
|
|
83
|
+
// stale plan is returned flagged, never silently replaced by a heuristic).
|
|
84
|
+
const appPlan = readAppPlan(db);
|
|
85
|
+
if (appPlan.kind === "plan")
|
|
86
|
+
return formatAppPlan(appPlan, params);
|
|
87
|
+
const standalone = await executeStandalonePlanner(params, liveIntel);
|
|
88
|
+
return { ...standalone, appPlanUnavailable: appPlan.reason };
|
|
89
|
+
}
|
|
90
|
+
async function executeStandalonePlanner(params, liveIntel) {
|
|
54
91
|
if (!liveIntel || !liveIntel.isInitialized()) {
|
|
55
92
|
return {
|
|
56
93
|
generatedAt: new Date().toISOString(),
|
|
@@ -65,21 +102,52 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
|
|
|
65
102
|
provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
|
|
66
103
|
};
|
|
67
104
|
}
|
|
105
|
+
// A long-lived server must not plan from the versions it read at startup:
|
|
106
|
+
// re-resolve if a lockfile (or node_modules) changed. Stat calls only.
|
|
107
|
+
liveIntel.refreshIfLockfilesChanged();
|
|
68
108
|
const includeDev = params.include_dev ?? false;
|
|
109
|
+
const only = params.package?.trim();
|
|
110
|
+
const named = (name, ecosystem) => !only || namesPackage(only, name, ecosystem);
|
|
69
111
|
let deps = liveIntel.getResolvedDeps();
|
|
70
112
|
if (!includeDev)
|
|
71
113
|
deps = deps.filter((d) => !d.isDev);
|
|
114
|
+
if (only)
|
|
115
|
+
deps = deps.filter((d) => named(d.name, d.ecosystem));
|
|
72
116
|
// Fetch registry data (live lookups for direct deps; cached where fresh)
|
|
73
117
|
const registryData = await liveIntel.fetchRegistryHealth(deps);
|
|
74
|
-
|
|
75
|
-
const
|
|
118
|
+
const drift = liveIntel.getInstallDrift();
|
|
119
|
+
const root = liveIntel.getProjectRoot() ?? process.cwd();
|
|
120
|
+
// Vulnerability data at the plan's scope, loaded here (bounded) when no scan
|
|
121
|
+
// has run. Telling the agent to "run vulnerability_scan first" made the first
|
|
122
|
+
// plan of every session CVE-blind (2026-10-03 agent eval). A plan that
|
|
123
|
+
// includes dev dependencies must not read a scan that left them out, so
|
|
124
|
+
// ensureVulnerabilities rescans at the wider scope when needed.
|
|
125
|
+
const vulnResult = (await liveIntel.ensureVulnerabilities(root, PLANNER_SCAN_TIMEOUT_MS, { includeDev })) ?? liveIntel.getVulnerabilities();
|
|
76
126
|
const vulnerabilityDataAvailable = vulnResult !== null;
|
|
127
|
+
// Keyed by ecosystem + name + INSTANCE version. A name-only key attached every
|
|
128
|
+
// instance's CVEs to whichever row asked first: the direct anyhow 1.0.104 was
|
|
129
|
+
// charged with RUSTSEC-2026-0190 (fixed in 1.0.103 — it isn't affected) while
|
|
130
|
+
// the actually-vulnerable transitive anyhow 1.0.102 was hidden by the
|
|
131
|
+
// direct-name skip below. Scan entries are created per scanned instance, so
|
|
132
|
+
// the instance version is exact.
|
|
133
|
+
const instanceKey = (ecosystem, name, version) => `${ecosystem}\0${name}\0${version ?? "unknown"}`;
|
|
77
134
|
const vulnsByPackage = new Map();
|
|
135
|
+
// "X is unmaintained" (RUSTSEC informational) is not a CVE and has nothing
|
|
136
|
+
// to upgrade to; it was counted as "1 known CVE" on paste, ttf-parser and
|
|
137
|
+
// five unic-* crates. Counted separately, never ranked.
|
|
138
|
+
const maintenanceNotices = new Set();
|
|
78
139
|
if (vulnResult) {
|
|
79
140
|
for (const v of vulnResult.vulnerabilities) {
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
141
|
+
if (!named(v.package, v.ecosystem))
|
|
142
|
+
continue;
|
|
143
|
+
if (isMaintenanceNotice(v)) {
|
|
144
|
+
maintenanceNotices.add(`${v.package}@${v.currentVersion}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const key = instanceKey(v.ecosystem, v.package, v.currentVersion);
|
|
148
|
+
if (!vulnsByPackage.has(key))
|
|
149
|
+
vulnsByPackage.set(key, []);
|
|
150
|
+
vulnsByPackage.get(key).push({
|
|
83
151
|
severity: v.severity,
|
|
84
152
|
vulnId: v.vulnId,
|
|
85
153
|
summary: v.summary,
|
|
@@ -92,24 +160,36 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
|
|
|
92
160
|
const recommendations = [];
|
|
93
161
|
const directPackages = new Set();
|
|
94
162
|
for (const dep of registryData) {
|
|
95
|
-
directPackages.add(
|
|
163
|
+
directPackages.add(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
|
|
96
164
|
const reasons = [];
|
|
97
165
|
let risk = "low";
|
|
98
|
-
|
|
166
|
+
const latestStable = dep.latestStableVersion || dep.latestVersion;
|
|
167
|
+
let targetVersion = latestStable;
|
|
99
168
|
let platformActive = true;
|
|
169
|
+
let fixable = false;
|
|
100
170
|
// Check vulnerabilities
|
|
101
|
-
const vulns = vulnsByPackage.get(dep.name);
|
|
171
|
+
const vulns = vulnsByPackage.get(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
|
|
102
172
|
if (vulns && vulns.length > 0) {
|
|
103
|
-
|
|
104
|
-
reasons.push(`${vulns.length} known CVE${vulns.length !== 1 ? "s" : ""} (${vulns.map((v) => v.vulnId).join(", ")})`);
|
|
173
|
+
reasons.push(`${vulns.length} known vulnerabilit${vulns.length !== 1 ? "ies" : "y"} (${vulns.map((v) => v.vulnId).join(", ")})`);
|
|
105
174
|
if (vulns.every((v) => !v.platformActive)) {
|
|
175
|
+
// Code this host never compiles is not this host's exposure: listed,
|
|
176
|
+
// never ranked as high (openssl topped the plan on Windows).
|
|
106
177
|
platformActive = false;
|
|
178
|
+
risk = "low";
|
|
107
179
|
reasons.push("Affected code is gated behind a target not active on this platform");
|
|
108
180
|
}
|
|
109
|
-
|
|
181
|
+
else {
|
|
182
|
+
risk = severityToRisk(vulns.filter((v) => v.platformActive).map((v) => v.severity));
|
|
183
|
+
}
|
|
184
|
+
// The smallest version that fixes every fixable advisory: each
|
|
185
|
+
// fixedVersion is already the fix on the installed release line.
|
|
110
186
|
const fixedVersions = vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f));
|
|
111
|
-
if (fixedVersions.length > 0
|
|
112
|
-
targetVersion =
|
|
187
|
+
if (fixedVersions.length > 0) {
|
|
188
|
+
targetVersion = maxVersion(fixedVersions, dep.ecosystem);
|
|
189
|
+
fixable = true;
|
|
190
|
+
if (fixedVersions.length < vulns.length) {
|
|
191
|
+
reasons.push(`${vulns.length - fixedVersions.length} of them have no published fix`);
|
|
192
|
+
}
|
|
113
193
|
}
|
|
114
194
|
}
|
|
115
195
|
// Check deprecation
|
|
@@ -133,51 +213,89 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
|
|
|
133
213
|
reasons.push(`${d.patch} patch${d.patch !== 1 ? "es" : ""} behind`);
|
|
134
214
|
}
|
|
135
215
|
}
|
|
136
|
-
//
|
|
216
|
+
// Install drift: node_modules holds another version than this lockfile
|
|
217
|
+
// row, and until a reinstall the installed copy's advisories are the real
|
|
218
|
+
// exposure. Without this, a patched, up-to-date lockfile version had no
|
|
219
|
+
// reasons and the row was skipped, so a vulnerable installed copy never
|
|
220
|
+
// appeared in the plan at all.
|
|
221
|
+
const lockfileReasons = reasons.length;
|
|
222
|
+
const drifted = dep.ecosystem === "npm" ? driftFor(drift, dep.name, dep.currentVersion) : [];
|
|
223
|
+
let installedVersion;
|
|
224
|
+
let installFix;
|
|
225
|
+
if (drifted.length > 0) {
|
|
226
|
+
installedVersion = [...new Set(drifted.map((r) => r.installedVersion))].join(", ");
|
|
227
|
+
installFix = drifted[0].fix;
|
|
228
|
+
const where = dirsLabel(drifted.map((r) => r.dir), (d) => relativeDir(d, root));
|
|
229
|
+
reasons.push(`node_modules has ${installedVersion}, not the lockfile's ${dep.currentVersion} — run \`${installFix}\` in ${where}`);
|
|
230
|
+
for (const installed of new Set(drifted.map((r) => r.installedVersion))) {
|
|
231
|
+
const installedVulns = vulnsByPackage.get(instanceKey("npm", dep.name, installed));
|
|
232
|
+
if (!installedVulns || installedVulns.length === 0)
|
|
233
|
+
continue;
|
|
234
|
+
reasons.push(`The installed ${installed} has ${installedVulns.length} known CVE${installedVulns.length !== 1 ? "s" : ""} (${installedVulns.map((v) => v.vulnId).join(", ")})`);
|
|
235
|
+
risk = higherRisk(risk, severityToRisk(installedVulns.map((v) => v.severity)));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Skip if no upgrade needed (no vuln, not deprecated, not behind, no drift => no reasons)
|
|
137
239
|
if (reasons.length === 0)
|
|
138
240
|
continue;
|
|
139
241
|
// Never recommend a downgrade: when the current version is a prerelease
|
|
140
242
|
// AHEAD of the latest stable (rsa 0.10.0-rc.18 vs stable 0.9.10), the
|
|
141
|
-
// stable line is not an upgrade target.
|
|
142
|
-
// fix version that IS ahead of current, else no target ("review").
|
|
243
|
+
// stable line is not an upgrade target.
|
|
143
244
|
if (targetVersion &&
|
|
144
245
|
dep.currentVersion &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.map((v) => v.fixedVersion)
|
|
148
|
-
.filter((f) => Boolean(f))
|
|
149
|
-
.filter((f) => compareSemver(f, dep.currentVersion) > 0);
|
|
150
|
-
targetVersion = vulnFixesAhead.length > 0 ? maxSemver(vulnFixesAhead) : null;
|
|
246
|
+
compareVersions(dep.currentVersion, targetVersion, dep.ecosystem) >= 0) {
|
|
247
|
+
targetVersion = null;
|
|
151
248
|
}
|
|
152
|
-
const
|
|
153
|
-
|
|
249
|
+
const upgradeType = upgradeTypeBetween(dep.currentVersion, targetVersion);
|
|
250
|
+
// Drift is the only problem: the lockfile's version is the target, and
|
|
251
|
+
// reinstalling it is the whole step.
|
|
252
|
+
const reinstallOnly = drifted.length > 0 && lockfileReasons === 0;
|
|
253
|
+
const vulnerable = Boolean(vulns && vulns.length > 0);
|
|
254
|
+
const action = reinstallOnly
|
|
255
|
+
? "reinstall"
|
|
256
|
+
: vulnerable && !platformActive
|
|
257
|
+
? "not_built_on_this_host"
|
|
258
|
+
: vulnerable && !fixable
|
|
259
|
+
? "no_fix_available"
|
|
260
|
+
: targetVersion
|
|
261
|
+
? "upgrade_direct"
|
|
262
|
+
: "no_fix_available";
|
|
154
263
|
recommendations.push({
|
|
155
264
|
package: dep.name,
|
|
156
265
|
ecosystem: dep.ecosystem,
|
|
157
266
|
currentVersion: dep.currentVersion,
|
|
158
|
-
targetVersion,
|
|
267
|
+
targetVersion: reinstallOnly ? dep.currentVersion : targetVersion,
|
|
268
|
+
...(latestStable && latestStable !== targetVersion ? { latestVersion: latestStable } : {}),
|
|
159
269
|
upgradeType,
|
|
160
270
|
risk,
|
|
161
271
|
reasons,
|
|
162
|
-
breaking:
|
|
272
|
+
breaking: upgradeType === "major",
|
|
163
273
|
isDev: dep.isDev,
|
|
164
274
|
scope: "direct",
|
|
165
|
-
action
|
|
275
|
+
action,
|
|
166
276
|
platformActive,
|
|
277
|
+
...(installedVersion ? { installedVersion, installFix } : {}),
|
|
167
278
|
});
|
|
168
279
|
}
|
|
169
280
|
// Transitive vulnerable packages — previously counted in scan totals but
|
|
170
281
|
// never recommended. Surfaced as waiting_on_upstream with honest labels.
|
|
282
|
+
// The skip is instance-aware: a transitive instance at a DIFFERENT version
|
|
283
|
+
// than a same-named direct dep is its own finding (anyhow 1.0.102 transitive
|
|
284
|
+
// beside anyhow 1.0.104 direct), not a duplicate of the direct row.
|
|
171
285
|
if (vulnResult) {
|
|
172
286
|
const transitive = new Map();
|
|
173
287
|
for (const v of vulnResult.vulnerabilities) {
|
|
174
288
|
if (v.isDirect)
|
|
175
289
|
continue;
|
|
176
|
-
if (
|
|
290
|
+
if (!named(v.package, v.ecosystem))
|
|
291
|
+
continue;
|
|
292
|
+
if (isMaintenanceNotice(v))
|
|
293
|
+
continue;
|
|
294
|
+
if (directPackages.has(instanceKey(v.ecosystem, v.package, v.currentVersion)))
|
|
177
295
|
continue;
|
|
178
296
|
if (!includeDev && v.isDev && v.devScopeKnown)
|
|
179
297
|
continue;
|
|
180
|
-
const key =
|
|
298
|
+
const key = instanceKey(v.ecosystem, v.package, v.currentVersion);
|
|
181
299
|
if (!transitive.has(key))
|
|
182
300
|
transitive.set(key, []);
|
|
183
301
|
transitive.get(key).push(v);
|
|
@@ -187,33 +305,44 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
|
|
|
187
305
|
const fixedVersions = entries.map((e) => e.fixedVersion).filter((f) => Boolean(f));
|
|
188
306
|
const platformActive = !entries.every((e) => !e.platformActive);
|
|
189
307
|
const reasons = [
|
|
190
|
-
`${entries.length} known
|
|
308
|
+
`${entries.length} known vulnerabilit${entries.length !== 1 ? "ies" : "y"} (${entries.map((e) => e.vulnId).join(", ")})`,
|
|
191
309
|
"Transitive dependency — not declared in your manifest; the fix arrives via a parent package update or a lockfile refresh",
|
|
192
310
|
];
|
|
193
311
|
if (!platformActive) {
|
|
194
312
|
reasons.push("Affected code is gated behind a target not active on this platform");
|
|
195
313
|
}
|
|
314
|
+
const target = fixedVersions.length > 0 ? maxVersion(fixedVersions, first.ecosystem) : null;
|
|
196
315
|
recommendations.push({
|
|
197
316
|
package: first.package,
|
|
198
317
|
ecosystem: first.ecosystem,
|
|
199
318
|
currentVersion: first.currentVersion,
|
|
200
|
-
targetVersion:
|
|
201
|
-
upgradeType:
|
|
202
|
-
risk: severityToRisk(entries.map((e) => e.severity)),
|
|
319
|
+
targetVersion: target,
|
|
320
|
+
upgradeType: upgradeTypeBetween(first.currentVersion, target),
|
|
321
|
+
risk: platformActive ? severityToRisk(entries.filter((e) => e.platformActive).map((e) => e.severity)) : "low",
|
|
203
322
|
reasons,
|
|
204
323
|
breaking: false,
|
|
205
324
|
isDev: entries.every((e) => e.isDev),
|
|
206
325
|
scope: "transitive",
|
|
207
|
-
action: "waiting_on_upstream",
|
|
326
|
+
action: !platformActive ? "not_built_on_this_host" : target ? "waiting_on_upstream" : "no_fix_available",
|
|
208
327
|
platformActive,
|
|
209
328
|
});
|
|
210
329
|
}
|
|
211
330
|
}
|
|
331
|
+
// Defensive dedupe: identical (ecosystem, package, version, scope) rows can
|
|
332
|
+
// still arrive when upstream dep lists carry duplicates. Keep the first.
|
|
333
|
+
const seenRecs = new Set();
|
|
334
|
+
const deduped = recommendations.filter((r) => {
|
|
335
|
+
const key = `${r.ecosystem}\0${r.package}\0${r.currentVersion ?? ""}\0${r.scope}`;
|
|
336
|
+
if (seenRecs.has(key))
|
|
337
|
+
return false;
|
|
338
|
+
seenRecs.add(key);
|
|
339
|
+
return true;
|
|
340
|
+
});
|
|
212
341
|
// Filter by risk threshold
|
|
213
342
|
const threshold = params.risk_threshold ?? "all";
|
|
214
343
|
const filtered = threshold === "all"
|
|
215
|
-
?
|
|
216
|
-
:
|
|
344
|
+
? deduped
|
|
345
|
+
: deduped.filter((r) => RISK_LEVELS[r.risk] >= RISK_LEVELS[threshold]);
|
|
217
346
|
// Sort: critical > high > medium > low, then fixable-now (direct) before
|
|
218
347
|
// waiting-on-upstream (transitive), then non-breaking first (quick wins).
|
|
219
348
|
filtered.sort((a, b) => {
|
|
@@ -228,22 +357,41 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
|
|
|
228
357
|
});
|
|
229
358
|
const maxRecs = params.max_recommendations ?? 20;
|
|
230
359
|
const limited = filtered.slice(0, maxRecs);
|
|
231
|
-
const
|
|
232
|
-
const quickWins =
|
|
233
|
-
const breakingChanges =
|
|
234
|
-
const waitingOnUpstream = limited.filter((r) => r.
|
|
360
|
+
const upgrades = limited.filter((r) => r.scope === "direct" && r.action === "upgrade_direct");
|
|
361
|
+
const quickWins = upgrades.filter((r) => !r.breaking).length;
|
|
362
|
+
const breakingChanges = upgrades.filter((r) => r.breaking).length;
|
|
363
|
+
const waitingOnUpstream = limited.filter((r) => r.action === "waiting_on_upstream").length;
|
|
364
|
+
const reinstalls = limited.filter((r) => r.action === "reinstall").length;
|
|
365
|
+
const noFix = limited.filter((r) => r.action === "no_fix_available").length;
|
|
366
|
+
const inactive = limited.filter((r) => r.action === "not_built_on_this_host").length;
|
|
235
367
|
// Summary
|
|
236
368
|
const parts = [];
|
|
237
|
-
parts.push(
|
|
238
|
-
|
|
239
|
-
|
|
369
|
+
parts.push(filtered.length > limited.length
|
|
370
|
+
? `Showing ${limited.length} of ${filtered.length} recommendations (raise max_recommendations for the rest)`
|
|
371
|
+
: `${limited.length} recommendation${limited.length !== 1 ? "s" : ""}`);
|
|
372
|
+
if (upgrades.length > 0)
|
|
373
|
+
parts.push(`${upgrades.length} upgradable directly (${quickWins} non-breaking, ${breakingChanges} crossing a major version)`);
|
|
374
|
+
if (reinstalls > 0)
|
|
375
|
+
parts.push(`${reinstalls} need only a reinstall (node_modules behind the lockfile)`);
|
|
240
376
|
if (waitingOnUpstream > 0)
|
|
241
377
|
parts.push(`${waitingOnUpstream} transitive, waiting on upstream`);
|
|
378
|
+
if (noFix > 0)
|
|
379
|
+
parts.push(`${noFix} vulnerable with no fixed release yet`);
|
|
380
|
+
if (inactive > 0)
|
|
381
|
+
parts.push(`${inactive} vulnerable only in code not built on this host`);
|
|
242
382
|
const criticalCount = limited.filter((r) => r.risk === "critical").length;
|
|
243
383
|
if (criticalCount > 0)
|
|
244
384
|
parts.push(`${criticalCount} critical`);
|
|
385
|
+
if (maintenanceNotices.size > 0) {
|
|
386
|
+
parts.push(`${maintenanceNotices.size} unmaintained-package notice${maintenanceNotices.size !== 1 ? "s" : ""} not ranked (no fix exists; see vulnerability_scan maintenance_notices)`);
|
|
387
|
+
}
|
|
388
|
+
const nothing = vulnerabilityDataAvailable ? "nothing to upgrade (no known vulnerability, deprecation, or newer release of a direct dependency)" : "no vulnerability data to plan from";
|
|
389
|
+
if (only && limited.length === 0)
|
|
390
|
+
parts.push(emptyAnswerNote(only, liveIntel, includeDev, nothing));
|
|
245
391
|
if (!vulnerabilityDataAvailable) {
|
|
246
|
-
parts.push(
|
|
392
|
+
parts.push(liveIntel.isEnabled()
|
|
393
|
+
? `No vulnerability data: the OSV scan did not finish within ${PLANNER_SCAN_TIMEOUT_MS / 1000}s or OSV was unreachable, so this plan is CVE-blind. Call again to use the scan once it completes`
|
|
394
|
+
: "No vulnerability data: offline mode (FOURDA_OFFLINE), so this plan is CVE-blind");
|
|
247
395
|
}
|
|
248
396
|
return {
|
|
249
397
|
generatedAt: new Date().toISOString(),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default `vulnerability_scan` answer: one row per vulnerable package
|
|
3
|
+
* version, ranked, bounded, and saying what it left out.
|
|
4
|
+
*
|
|
5
|
+
* It used to list one row per advisory. Measured 2026-10-03 on the pre-publish
|
|
6
|
+
* benchmark: nodejs-goof (290 advisories over 137 package versions) came to
|
|
7
|
+
* ~25k tokens and hyper-term with devDependencies to ~26k, at or past the 25k
|
|
8
|
+
* an MCP answer may use in Claude Code before it is cut off, on exactly the
|
|
9
|
+
* projects where the scan matters most. An agent acts per package ("upgrade
|
|
10
|
+
* ejs 1.0.0 to 3.1.10"), so a row is a package version: its graded severity,
|
|
11
|
+
* the smallest version that fixes every fixable advisory on it, and the
|
|
12
|
+
* advisory count with the first ids. Every advisory is still counted in
|
|
13
|
+
* `by_severity`; `response_format: "detailed"` lists them one by one.
|
|
14
|
+
*/
|
|
15
|
+
import { type SeverityTier } from "../live/severity-scope.js";
|
|
16
|
+
import type { formatScanResult } from "./vulnerability-scan-format.js";
|
|
17
|
+
type FullScan = ReturnType<typeof formatScanResult>;
|
|
18
|
+
type FormattedVuln = FullScan["vulnerabilities"][number];
|
|
19
|
+
export interface PackageRow {
|
|
20
|
+
package: string;
|
|
21
|
+
current_version: string;
|
|
22
|
+
severity: SeverityTier;
|
|
23
|
+
advisories: number;
|
|
24
|
+
ids: string[];
|
|
25
|
+
fixed_version: string | null;
|
|
26
|
+
unfixed_advisories?: number;
|
|
27
|
+
is_direct: boolean | null;
|
|
28
|
+
is_dev?: true;
|
|
29
|
+
source_dirs: string[];
|
|
30
|
+
summary: string;
|
|
31
|
+
}
|
|
32
|
+
/** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
|
|
33
|
+
export declare function groupByPackage(rows: FormattedVuln[]): PackageRow[];
|
|
34
|
+
export declare function conciseScanResult(full: FullScan): {
|
|
35
|
+
scan_time: string;
|
|
36
|
+
project_root: string;
|
|
37
|
+
host_platform: string;
|
|
38
|
+
ecosystems_scanned: string[];
|
|
39
|
+
total_dependencies_scanned: number;
|
|
40
|
+
total_vulnerable_packages: number;
|
|
41
|
+
by_severity: import("../live/severity-scope.js").SeverityCounts;
|
|
42
|
+
vulnerable_packages: PackageRow[];
|
|
43
|
+
vulnerable_packages_note?: string | undefined;
|
|
44
|
+
recommendations: string[];
|
|
45
|
+
recommendations_note?: string | undefined;
|
|
46
|
+
not_listed: {
|
|
47
|
+
platform_inactive: {
|
|
48
|
+
count: number;
|
|
49
|
+
packages: string[];
|
|
50
|
+
};
|
|
51
|
+
maintenance_notices: {
|
|
52
|
+
count: number;
|
|
53
|
+
packages: string[];
|
|
54
|
+
};
|
|
55
|
+
install_drift: {
|
|
56
|
+
count: number;
|
|
57
|
+
vulnerable_installed: Record<string, unknown>[];
|
|
58
|
+
};
|
|
59
|
+
note: string;
|
|
60
|
+
};
|
|
61
|
+
_meta: {
|
|
62
|
+
source: string;
|
|
63
|
+
osv_cached: boolean;
|
|
64
|
+
offline: boolean;
|
|
65
|
+
relevance: string;
|
|
66
|
+
severity_rule: string;
|
|
67
|
+
lockfiles_read: number;
|
|
68
|
+
resolved_at: string | null;
|
|
69
|
+
privacy: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The default `vulnerability_scan` answer: one row per vulnerable package
|
|
4
|
+
* version, ranked, bounded, and saying what it left out.
|
|
5
|
+
*
|
|
6
|
+
* It used to list one row per advisory. Measured 2026-10-03 on the pre-publish
|
|
7
|
+
* benchmark: nodejs-goof (290 advisories over 137 package versions) came to
|
|
8
|
+
* ~25k tokens and hyper-term with devDependencies to ~26k, at or past the 25k
|
|
9
|
+
* an MCP answer may use in Claude Code before it is cut off, on exactly the
|
|
10
|
+
* projects where the scan matters most. An agent acts per package ("upgrade
|
|
11
|
+
* ejs 1.0.0 to 3.1.10"), so a row is a package version: its graded severity,
|
|
12
|
+
* the smallest version that fixes every fixable advisory on it, and the
|
|
13
|
+
* advisory count with the first ids. Every advisory is still counted in
|
|
14
|
+
* `by_severity`; `response_format: "detailed"` lists them one by one.
|
|
15
|
+
*/
|
|
16
|
+
import { compareVersions, maxVersion } from "../live/version-compare.js";
|
|
17
|
+
import { SEVERITY_RANK } from "../live/severity-scope.js";
|
|
18
|
+
const MAX_PACKAGES = 40;
|
|
19
|
+
const MAX_RECOMMENDATIONS = 25;
|
|
20
|
+
const IDS_PER_ROW = 3;
|
|
21
|
+
/** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
|
|
22
|
+
export function groupByPackage(rows) {
|
|
23
|
+
const groups = new Map();
|
|
24
|
+
for (const v of rows) {
|
|
25
|
+
const key = `${v.ecosystem}\0${v.package}\0${v.current_version}`;
|
|
26
|
+
groups.set(key, [...(groups.get(key) ?? []), v]);
|
|
27
|
+
}
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const vulns of groups.values()) {
|
|
30
|
+
const worst = vulns.reduce((a, b) => (SEVERITY_RANK[b.severity] > SEVERITY_RANK[a.severity] ? b : a));
|
|
31
|
+
const fixes = vulns.map((v) => v.fixed_version).filter((f) => Boolean(f));
|
|
32
|
+
const unfixed = vulns.length - fixes.length;
|
|
33
|
+
const fixed = fixes.length > 0 ? maxVersion(fixes, worst.ecosystem) : null;
|
|
34
|
+
// Unknown (null) only when no advisory row knows; any direct row makes it direct.
|
|
35
|
+
const direct = vulns.some((v) => v.is_direct === true) ? true : vulns.some((v) => v.is_direct === null) ? null : false;
|
|
36
|
+
out.push({
|
|
37
|
+
package: worst.package,
|
|
38
|
+
current_version: worst.current_version,
|
|
39
|
+
severity: worst.severity,
|
|
40
|
+
advisories: vulns.length,
|
|
41
|
+
ids: vulns.slice(0, IDS_PER_ROW).map((v) => v.vuln_id),
|
|
42
|
+
fixed_version: fixed,
|
|
43
|
+
...(unfixed > 0 ? { unfixed_advisories: unfixed } : {}),
|
|
44
|
+
is_direct: direct,
|
|
45
|
+
...(vulns.every((v) => v.is_dev) ? { is_dev: true } : {}),
|
|
46
|
+
source_dirs: [...new Set(vulns.flatMap((v) => v.source_dirs ?? []))],
|
|
47
|
+
summary: worst.summary.length > 120 ? `${worst.summary.slice(0, 117)}...` : worst.summary,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Worst first; at equal severity a direct dependency (you can bump it) first, then the busiest.
|
|
51
|
+
return out.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
|
|
52
|
+
Number(b.is_direct === true) - Number(a.is_direct === true) ||
|
|
53
|
+
b.advisories - a.advisories ||
|
|
54
|
+
a.package.localeCompare(b.package) ||
|
|
55
|
+
compareVersions(a.current_version, b.current_version, "npm"));
|
|
56
|
+
}
|
|
57
|
+
export function conciseScanResult(full) {
|
|
58
|
+
const names = (rows) => [...new Set(rows.map((v) => `${v.package}@${v.current_version}`))];
|
|
59
|
+
const vulnerableDrift = full.install_drift.filter((row) => row.vulnerable_installed === true);
|
|
60
|
+
const packages = groupByPackage(full.vulnerabilities);
|
|
61
|
+
const listed = packages.slice(0, MAX_PACKAGES);
|
|
62
|
+
const recommendations = full.recommendations.slice(0, MAX_RECOMMENDATIONS);
|
|
63
|
+
const omittedPackages = packages.length - listed.length;
|
|
64
|
+
const omittedRecommendations = full.recommendations.length - recommendations.length;
|
|
65
|
+
return {
|
|
66
|
+
scan_time: full.scan_time,
|
|
67
|
+
project_root: full.project_root,
|
|
68
|
+
host_platform: full.host_platform,
|
|
69
|
+
ecosystems_scanned: full.ecosystems_scanned,
|
|
70
|
+
total_dependencies_scanned: full.total_dependencies_scanned,
|
|
71
|
+
total_vulnerable_packages: full.total_vulnerable_packages,
|
|
72
|
+
// Every listed advisory, before grouping and trimming.
|
|
73
|
+
by_severity: full.by_severity,
|
|
74
|
+
vulnerable_packages: listed,
|
|
75
|
+
...(omittedPackages > 0
|
|
76
|
+
? { vulnerable_packages_note: `Showing the ${listed.length} most severe of ${packages.length} vulnerable package versions; pass severity_filter (e.g. "high") or response_format: "detailed" for the rest.` }
|
|
77
|
+
: {}),
|
|
78
|
+
recommendations,
|
|
79
|
+
...(omittedRecommendations > 0 ? { recommendations_note: `Showing ${recommendations.length} of ${full.recommendations.length}.` } : {}),
|
|
80
|
+
not_listed: {
|
|
81
|
+
platform_inactive: { count: full.platform_inactive_vulnerabilities.length, packages: names(full.platform_inactive_vulnerabilities) },
|
|
82
|
+
maintenance_notices: { count: full.maintenance_notices.length, packages: names(full.maintenance_notices) },
|
|
83
|
+
install_drift: { count: full.install_drift.length, vulnerable_installed: vulnerableDrift },
|
|
84
|
+
note: 'Pass response_format: "detailed" for one row per advisory, these rows, advisory references and resolution provenance.',
|
|
85
|
+
},
|
|
86
|
+
_meta: {
|
|
87
|
+
source: full._meta.source,
|
|
88
|
+
osv_cached: full._meta.osv_cached,
|
|
89
|
+
offline: full._meta.offline,
|
|
90
|
+
relevance: full._meta.relevance,
|
|
91
|
+
severity_rule: "`severity` is scope-adjusted (transitive caps critical at high; dev-only drops one level) and is the worst of the package version's advisories; detailed rows carry each advisory's own `advisory_severity`.",
|
|
92
|
+
lockfiles_read: full._meta.resolution.lockfiles.length,
|
|
93
|
+
resolved_at: full._meta.resolution.resolved_at,
|
|
94
|
+
privacy: full._meta.privacy,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|