").addClass("lsp-hover-quickview npm-hover-quickview");
+ const $doc = $("
").addClass("lsp-hover-doc").appendTo($content);
+
+ const meta = [info.version && ("v" + info.version), info.license]
+ .filter(Boolean).join(" · ");
+ const $title = $("
").append($("").text(info.name));
+ if (meta) {
+ $title.append(document.createTextNode(" "))
+ .append($("").css("opacity", 0.65).text(meta));
+ }
+ $doc.append($title);
+
+ if (info.description) {
+ $doc.append($("").text(info.description));
+ }
+ // links row: homepage on the left, "View docs" (the npm page, which renders the README
+ // for the declared version) pinned bottom-right
+ const $links = $("
").addClass("npm-hover-links");
+ if (info.homepage && /^https?:\/\//.test(info.homepage)) {
+ $links.append(_externalLink(Strings.NPM_HOVER_HOMEPAGE, info.homepage));
+ }
+ $links.append(_externalLink(Strings.NPM_HOVER_VIEW_DOCS, _npmPageUrl(info.name, declaredRange))
+ .addClass("npm-hover-docs-link"));
+ $doc.append($links);
+ return $content;
+ }
+
+ const provider = {
+ QUICK_VIEW_NAME: "npmPackageHover",
+
+ getQuickView: function (editor, pos, token, line) {
+ return new Promise(function (resolve, reject) {
+ if (!NpmHints.isPackageJson(editor)) {
+ reject();
+ return;
+ }
+ const ctxInfo = JSONUtils.getContextInfo(editor, pos, true);
+ if (!NpmHints.depContext(ctxInfo)) {
+ reject();
+ return;
+ }
+ // hovering the key gives the name directly; hovering the value gives it via keyName
+ const onKey = ctxInfo.tokenType === JSONUtils.TOKEN_KEY;
+ const name = onKey
+ ? JSONUtils.stripQuotes(ctxInfo.token.string).trim()
+ : ctxInfo.keyName;
+ if (!name) {
+ reject();
+ return;
+ }
+ // declared range: the hovered token itself in value position; when hovering the
+ // key, read it off the entry's line (JSONUtils does not fill valueName here)
+ let declaredRange;
+ if (onKey) {
+ const lineMatch = (line || editor.document.getLine(pos.line) || "")
+ .match(/:\s*"((?:[^"\\]|\\.)*)"/);
+ declaredRange = (lineMatch && lineMatch[1]) || "";
+ } else {
+ declaredRange = JSONUtils.stripQuotes(ctxInfo.token.string || "").trim();
+ }
+ NpmRegistry.getPackageInfo(name).then(function (info) {
+ resolve({
+ start: { line: pos.line, ch: ctxInfo.token.start },
+ end: { line: pos.line, ch: ctxInfo.token.end },
+ content: _content(info, declaredRange)
+ });
+ }).catch(function () {
+ reject(); // unknown/private package - show nothing
+ });
+ });
+ }
+ };
+
+ /**
+ * Register the dependency hover. Call once from appReady.
+ */
+ function init() {
+ QuickViewManager.registerQuickViewProvider(provider, ["json"]);
+ }
+
+ exports.init = init;
+ // for unit tests
+ exports._content = _content;
+ exports._npmPageUrl = _npmPageUrl;
+});
diff --git a/src/extensionsIntegrated/JSONSupport/NpmRegistry.js b/src/extensionsIntegrated/JSONSupport/NpmRegistry.js
new file mode 100644
index 0000000000..f634d85584
--- /dev/null
+++ b/src/extensionsIntegrated/JSONSupport/NpmRegistry.js
@@ -0,0 +1,266 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/**
+ * NpmRegistry - the npm registry / security-advisory client behind package.json intelligence.
+ *
+ * - searchPackages(query): package NAME suggestions (registry search API, CORS-open GET).
+ * - getVersions(name): version list + dist-tags (abbreviated registry doc, CORS-open GET).
+ * - getAdvisoriesBulk(pairs): security advisories for exact name@version pairs. The bulk endpoint
+ * is POST-only WITHOUT CORS headers (verified), so the browser build cannot reach it directly:
+ * transport is tiered - browser fetch first (in case CORS opens up), then the ph-npm-intel node
+ * connector on desktop, else advisories are unavailable (empty results).
+ *
+ * All results are TTL-cached (session-scoped). Network and clock are injectable for tests.
+ *
+ * @module extensionsIntegrated/JSONSupport/NpmRegistry
+ */
+define(function (require, exports, module) {
+
+
+ const NodeUtils = require("utils/NodeUtils"),
+ NodeConnector = require("NodeConnector");
+
+ const SEARCH_URL = "https://registry.npmjs.org/-/v1/search";
+ const PACKAGE_URL = "https://registry.npmjs.org/";
+ const ADVISORY_BULK_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk";
+
+ const NODE_NPM_INTEL_MODULE = "./npm-intel";
+ const NPM_INTEL_CONNECTOR_ID = "ph-npm-intel";
+
+ const SEARCH_TTL_MS = 5 * 60 * 1000;
+ const VERSIONS_TTL_MS = 10 * 60 * 1000;
+ const ADVISORY_TTL_MS = 60 * 60 * 1000;
+ const SEARCH_CACHE_MAX = 50;
+ const SEARCH_RESULT_SIZE = 20;
+
+ // Injectable for tests: _fetchJson(url, options) -> Promise. The default goes
+ // over window.fetch (cached reference, matching the security-conscious pattern used elsewhere).
+ const realFetch = window.fetch.bind(window);
+ let _fetchJson = function (url, options) {
+ return realFetch(url, options).then(function (response) {
+ if (!response.ok) {
+ throw new Error("npm registry request failed: " + response.status);
+ }
+ return response.json();
+ });
+ };
+ let _now = function () {
+ return Date.now();
+ };
+ let _fetcherInjected = false; // tests inject a fake fetcher - it must own ALL transports
+
+ const _searchCache = new Map(); // query -> {ts, results}
+ const _versionsCache = new Map(); // name -> {ts, result}
+ const _advisoryCache = new Map(); // "name@version" -> {ts, advisories[]} (empty = negative)
+
+ function _cacheGet(cache, key, ttl) {
+ const entry = cache.get(key);
+ if (entry && (_now() - entry.ts) < ttl) {
+ return entry.value;
+ }
+ cache.delete(key);
+ return null;
+ }
+
+ function _cacheSet(cache, key, value, maxEntries) {
+ if (maxEntries && cache.size >= maxEntries) {
+ // drop the oldest entry (Map preserves insertion order)
+ cache.delete(cache.keys().next().value);
+ }
+ cache.set(key, { ts: _now(), value: value });
+ }
+
+ /**
+ * Search the npm registry for package names. Results keep the registry's relevance order.
+ * @param {string} query - at least one character
+ * @return {Promise>}
+ */
+ function searchPackages(query) {
+ const cached = _cacheGet(_searchCache, query, SEARCH_TTL_MS);
+ if (cached) {
+ return Promise.resolve(cached);
+ }
+ const url = SEARCH_URL + "?text=" + encodeURIComponent(query) + "&size=" + SEARCH_RESULT_SIZE;
+ return _fetchJson(url).then(function (json) {
+ const results = ((json && json.objects) || []).map(function (entry) {
+ return {
+ name: entry.package.name,
+ version: entry.package.version,
+ description: entry.package.description || ""
+ };
+ });
+ _cacheSet(_searchCache, query, results, SEARCH_CACHE_MAX);
+ return results;
+ });
+ }
+
+ /**
+ * Fetch the version list + dist-tags of a package (abbreviated registry document).
+ * @param {string} name - the exact package name
+ * @return {Promise<{versions: string[], latest: ?string}>} versions in registry order
+ */
+ function getVersions(name) {
+ const cached = _cacheGet(_versionsCache, name, VERSIONS_TTL_MS);
+ if (cached) {
+ return Promise.resolve(cached);
+ }
+ // scoped names keep their "@" but the "/" must be encoded per registry URL rules
+ const url = PACKAGE_URL + encodeURIComponent(name).replace(/^%40/, "@");
+ return _fetchJson(url, {
+ headers: { "Accept": "application/vnd.npm.install-v1+json" }
+ }).then(function (json) {
+ const result = {
+ versions: Object.keys((json && json.versions) || {}),
+ latest: (json && json["dist-tags"] && json["dist-tags"].latest) || null
+ };
+ _cacheSet(_versionsCache, name, result);
+ return result;
+ });
+ }
+
+ const _packageInfoCache = new Map(); // name -> {ts, info}
+
+ /**
+ * Fetch a package's latest-version summary (small document: name, version, description,
+ * homepage, license) - powers the dependency hover.
+ * @param {string} name - the exact package name
+ * @return {Promise<{name, version, description, homepage, license}>}
+ */
+ function getPackageInfo(name) {
+ const cached = _cacheGet(_packageInfoCache, name, VERSIONS_TTL_MS);
+ if (cached) {
+ return Promise.resolve(cached);
+ }
+ const url = PACKAGE_URL + encodeURIComponent(name).replace(/^%40/, "@") + "/latest";
+ return _fetchJson(url).then(function (json) {
+ const info = {
+ name: (json && json.name) || name,
+ version: (json && json.version) || "",
+ description: (json && json.description) || "",
+ homepage: (json && json.homepage) || "",
+ license: (json && typeof json.license === "string" && json.license) || ""
+ };
+ _cacheSet(_packageInfoCache, name, info);
+ return info;
+ });
+ }
+
+ // ----- advisories -----------------------------------------------------------------------------
+
+ let _nodeConnectorPromise = null;
+
+ function _getNodeIntelConnector() {
+ if (!_nodeConnectorPromise) {
+ _nodeConnectorPromise = (async function () {
+ await NodeUtils._loadNodeExtensionModule(NODE_NPM_INTEL_MODULE);
+ return NodeConnector.createNodeConnector(NPM_INTEL_CONNECTOR_ID, {});
+ }());
+ }
+ return _nodeConnectorPromise;
+ }
+
+ // The bulk endpoint sends no CORS headers (verified), so a browser fetch ALWAYS fails - and
+ // Chromium logs the preflight failure to the console regardless of our catch, which reads as an
+ // alarming error on every scan. Desktop therefore goes straight to the node helper; browser
+ // builds still try fetch (their only option - degrades quietly, and starts working by itself
+ // if the registry ever opens up CORS). Injected test fetchers take the fetch path everywhere.
+ function _browserFetchAdvisories(body) {
+ return _fetchJson(ADVISORY_BULK_URL, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ }
+
+ function _fetchAdvisoriesRaw(body) {
+ if (_fetcherInjected || !Phoenix.isNativeApp) {
+ return _browserFetchAdvisories(body);
+ }
+ return _getNodeIntelConnector().then(function (conn) {
+ return conn.execPeer("fetchAdvisoriesBulk", { body: body });
+ });
+ }
+
+ /**
+ * Look up security advisories for exact package versions.
+ * @param {Array<{name: string, version: string}>} pairs - resolved name@version pairs
+ * @return {Promise>} map of package name -> advisories affecting the
+ * given version ({id, url, title, severity, vulnerable_versions, ...}); resolves {} when the
+ * advisory data source is unreachable (browser build without CORS) - callers degrade quietly.
+ */
+ function getAdvisoriesBulk(pairs) {
+ const result = {};
+ const uncached = [];
+ pairs.forEach(function (pair) {
+ const key = pair.name + "@" + pair.version;
+ const cached = _cacheGet(_advisoryCache, key, ADVISORY_TTL_MS);
+ if (cached) {
+ if (cached.length) {
+ result[pair.name] = cached;
+ }
+ } else {
+ uncached.push(pair);
+ }
+ });
+ if (!uncached.length) {
+ return Promise.resolve(result);
+ }
+ const body = {};
+ uncached.forEach(function (pair) {
+ body[pair.name] = (body[pair.name] || []).concat(pair.version);
+ });
+ return _fetchAdvisoriesRaw(body).then(function (json) {
+ uncached.forEach(function (pair) {
+ const advisories = (json && json[pair.name]) || [];
+ _cacheSet(_advisoryCache, pair.name + "@" + pair.version, advisories);
+ if (advisories.length) {
+ result[pair.name] = advisories;
+ }
+ });
+ return result;
+ }).catch(function () {
+ // advisory data unavailable - not an error state for the editor
+ return result;
+ });
+ }
+
+ // ----- test hooks -----------------------------------------------------------------------------
+
+ function _setFetcherForTests(fetchJsonFn) {
+ _fetchJson = fetchJsonFn;
+ _fetcherInjected = true;
+ _searchCache.clear();
+ _versionsCache.clear();
+ _advisoryCache.clear();
+ _packageInfoCache.clear();
+ }
+
+ function _setNowForTests(nowFn) {
+ _now = nowFn;
+ }
+
+ exports.searchPackages = searchPackages;
+ exports.getVersions = getVersions;
+ exports.getPackageInfo = getPackageInfo;
+ exports.getAdvisoriesBulk = getAdvisoriesBulk;
+ exports._setFetcherForTests = _setFetcherForTests;
+ exports._setNowForTests = _setNowForTests;
+});
diff --git a/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js
new file mode 100644
index 0000000000..49096fe926
--- /dev/null
+++ b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js
@@ -0,0 +1,298 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/**
+ * VulnerabilityInspection - squigglies on vulnerable package.json dependencies.
+ *
+ * For each entry in the dependency sections, the declared semver RANGE is resolved to the exact
+ * version npm would install (semver.maxSatisfying over the package's real version list), then the
+ * npm security-advisory bulk endpoint - the same data `npm audit` uses - reports advisories for
+ * those versions. Hits surface as CodeInspection errors underlining the whole `"name": "range"`
+ * entry, severity-mapped (critical/high -> error, moderate -> warning, low -> info).
+ *
+ * The scan callback NEVER blocks on the network: it returns the last computed results immediately
+ * and kicks a single-flighted background refresh (keyed on a hash of the dependency sections, so
+ * unrelated edits don't refetch); when fresh results differ, CodeInspection.requestRun() re-runs
+ * the panel - the same push model the LSP linting provider uses.
+ *
+ * @module extensionsIntegrated/JSONSupport/VulnerabilityInspection
+ */
+define(function (require, exports, module) {
+
+
+ const CodeInspection = require("language/CodeInspection"),
+ StringUtils = require("utils/StringUtils"),
+ Strings = require("strings"),
+ semver = require("thirdparty/semver.browser"),
+ NpmRegistry = require("./NpmRegistry"),
+ NpmHints = require("./NpmHints");
+
+ const PACKAGE_JSON_RE = /(^|\/)package\.json$/;
+ const MAX_ADVISORIES_PER_DEP = 3;
+ const MAX_DEPS_PER_SCAN = 150; // resolution needs one versions-fetch per distinct dep
+
+ // fullPath -> {depHash, errors} - the last computed results, returned synchronously by scans
+ const _resultsByPath = new Map();
+ // fullPath -> true while a background refresh is in flight (single-flight per file)
+ const _refreshing = new Map();
+
+ // ----- dependency-entry position scanning ----------------------------------------------------
+
+ // Walk a JSON object slice from `openBrace` (index of "{") to its matching "}", skipping string
+ // literals. Returns the index just past the matching brace, or -1.
+ function _matchBrace(text, openBrace) {
+ let depth = 0;
+ let i = openBrace;
+ while (i < text.length) {
+ const ch = text[i];
+ if (ch === "\"") {
+ i++;
+ while (i < text.length && text[i] !== "\"") {
+ i += (text[i] === "\\") ? 2 : 1;
+ }
+ } else if (ch === "{") {
+ depth++;
+ } else if (ch === "}") {
+ depth--;
+ if (depth === 0) {
+ return i + 1;
+ }
+ }
+ i++;
+ }
+ return -1;
+ }
+
+ /**
+ * Pure text scan locating every `"name": "range"` entry inside the dependency sections.
+ * Exported for unit tests.
+ * @param {string} text - the package.json source
+ * @return {Array<{name: string, range: string, pos: {line, ch}, endPos: {line, ch}, section: string}>}
+ */
+ function _findDependencyRanges(text) {
+ // precompute line starts for offset -> {line, ch}
+ const lineStarts = [0];
+ for (let i = 0; i < text.length; i++) {
+ if (text[i] === "\n") {
+ lineStarts.push(i + 1);
+ }
+ }
+ function toPos(offset) {
+ let low = 0, high = lineStarts.length - 1;
+ while (low < high) {
+ const mid = Math.floor((low + high + 1) / 2);
+ if (lineStarts[mid] <= offset) {
+ low = mid;
+ } else {
+ high = mid - 1;
+ }
+ }
+ return { line: low, ch: offset - lineStarts[low] };
+ }
+
+ const results = [];
+ NpmHints.DEP_SECTIONS.forEach(function (section) {
+ const keyIdx = text.indexOf("\"" + section + "\"");
+ if (keyIdx === -1) {
+ return;
+ }
+ const openBrace = text.indexOf("{", keyIdx);
+ if (openBrace === -1) {
+ return;
+ }
+ const endIdx = _matchBrace(text, openBrace);
+ if (endIdx === -1) {
+ return;
+ }
+ const slice = text.slice(openBrace, endIdx);
+ const entryRe = /"((?:[^"\\]|\\.)+)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
+ let match;
+ while ((match = entryRe.exec(slice)) !== null) {
+ results.push({
+ name: match[1],
+ range: match[2],
+ section: section,
+ pos: toPos(openBrace + match.index),
+ endPos: toPos(openBrace + match.index + match[0].length)
+ });
+ }
+ });
+ return results;
+ }
+
+ // ----- severity mapping -----------------------------------------------------------------------
+
+ function _severityToType(severity) {
+ switch (severity) {
+ case "critical":
+ case "high":
+ return CodeInspection.Type.ERROR;
+ case "moderate":
+ return CodeInspection.Type.WARNING;
+ default:
+ return CodeInspection.Type.META;
+ }
+ }
+
+ function _severityLabel(severity) {
+ switch (severity) {
+ case "critical": return Strings.NPM_SEVERITY_CRITICAL;
+ case "high": return Strings.NPM_SEVERITY_HIGH;
+ case "moderate": return Strings.NPM_SEVERITY_MODERATE;
+ default: return Strings.NPM_SEVERITY_LOW;
+ }
+ }
+
+ const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3 };
+
+ // Exported for unit tests: advisories + resolved version + entry -> CodeInspection errors.
+ function _errorsForEntry(entry, resolvedVersion, advisories) {
+ const seen = new Set();
+ return advisories.filter(function (advisory) {
+ if (seen.has(advisory.id)) {
+ return false;
+ }
+ seen.add(advisory.id);
+ return true;
+ }).sort(function (a, b) {
+ // `in`-guarded, not ||: critical maps to rank 0, which is falsy. Also NOT `??` -
+ // the prod bundle's minifier (gulp-minify's terser 3) predates nullish coalescing
+ // and dies on it with an unhelpful "reading 'replace'" error in makeJSDist.
+ const rankA = (a.severity in SEVERITY_ORDER) ? SEVERITY_ORDER[a.severity] : 9;
+ const rankB = (b.severity in SEVERITY_ORDER) ? SEVERITY_ORDER[b.severity] : 9;
+ return rankA - rankB;
+ }).slice(0, MAX_ADVISORIES_PER_DEP).map(function (advisory) {
+ return {
+ pos: entry.pos,
+ endPos: entry.endPos,
+ message: StringUtils.format(Strings.NPM_VULN_MESSAGE,
+ entry.name, resolvedVersion, advisory.title, _severityLabel(advisory.severity)),
+ type: _severityToType(advisory.severity)
+ };
+ });
+ }
+
+ // ----- background refresh ---------------------------------------------------------------------
+
+ function _depHash(entries) {
+ return entries.map(function (e) {
+ return e.name + "@" + e.range;
+ }).join("|");
+ }
+
+ async function _computeErrors(entries) {
+ // resolve every declared range to the exact version npm would install
+ const resolved = [];
+ for (const entry of entries) {
+ if (!semver.validRange(entry.range)) {
+ continue; // file:, git+, workspace:, tags - not semver-resolvable
+ }
+ try {
+ const info = await NpmRegistry.getVersions(entry.name);
+ const version = semver.maxSatisfying(info.versions, entry.range);
+ if (version) {
+ resolved.push({ entry: entry, version: version });
+ }
+ } catch (e) {
+ // unknown/private package - skip quietly
+ }
+ }
+ if (!resolved.length) {
+ return [];
+ }
+ const advisoryMap = await NpmRegistry.getAdvisoriesBulk(resolved.map(function (r) {
+ return { name: r.entry.name, version: r.version };
+ }));
+ const errors = [];
+ resolved.forEach(function (r) {
+ const advisories = advisoryMap[r.entry.name];
+ if (advisories && advisories.length) {
+ errors.push.apply(errors, _errorsForEntry(r.entry, r.version, advisories));
+ }
+ });
+ return errors;
+ }
+
+ function _refreshInBackground(text, fullPath) {
+ let entries = _findDependencyRanges(text);
+ if (entries.length > MAX_DEPS_PER_SCAN) {
+ entries = entries.slice(0, MAX_DEPS_PER_SCAN);
+ }
+ const depHash = _depHash(entries);
+ const cached = _resultsByPath.get(fullPath);
+ if (cached && cached.depHash === depHash) {
+ return; // dependencies unchanged - cached errors remain valid
+ }
+ if (_refreshing.get(fullPath)) {
+ return; // single-flight; the running refresh re-checks the hash when done
+ }
+ _refreshing.set(fullPath, true);
+ _computeErrors(entries).then(function (errors) {
+ const previous = _resultsByPath.get(fullPath);
+ _resultsByPath.set(fullPath, { depHash: depHash, errors: errors });
+ const changed = !previous || JSON.stringify(previous.errors) !== JSON.stringify(errors);
+ if (changed) {
+ CodeInspection.requestRun();
+ }
+ }).catch(function () {
+ // network unavailable - keep whatever we had
+ }).finally(function () {
+ _refreshing.delete(fullPath);
+ });
+ }
+
+ // ----- provider -------------------------------------------------------------------------------
+
+ function _scanFileAsync(text, fullPath) {
+ const deferred = new $.Deferred();
+ if (!PACKAGE_JSON_RE.test(fullPath)) {
+ deferred.resolve();
+ return deferred.promise();
+ }
+ // malformed JSON: the JSON language server reports the syntax error; stale dep positions
+ // would point at the wrong text, so hold results until it parses again
+ try {
+ JSON.parse(text);
+ } catch (e) {
+ deferred.resolve();
+ return deferred.promise();
+ }
+ _refreshInBackground(text, fullPath);
+ const cached = _resultsByPath.get(fullPath);
+ deferred.resolve(cached && cached.errors.length ? { errors: cached.errors } : undefined);
+ return deferred.promise();
+ }
+
+ /**
+ * Register the inspection provider. Call once from appReady.
+ */
+ function init() {
+ CodeInspection.register("json", {
+ name: Strings.NPM_VULN_PROVIDER_NAME,
+ scanFileAsync: _scanFileAsync
+ });
+ }
+
+ exports.init = init;
+ // for unit tests
+ exports._findDependencyRanges = _findDependencyRanges;
+ exports._errorsForEntry = _errorsForEntry;
+ exports._severityToType = _severityToType;
+});
diff --git a/src/extensionsIntegrated/JSONSupport/main.js b/src/extensionsIntegrated/JSONSupport/main.js
new file mode 100644
index 0000000000..9d50a9fe69
--- /dev/null
+++ b/src/extensionsIntegrated/JSONSupport/main.js
@@ -0,0 +1,50 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/**
+ * JSONSupport - code intelligence for JSON files:
+ * - schema-aware completion / hover / validation via the JSON language server (desktop only),
+ * - npm package name and version completion inside package.json dependencies (all builds),
+ * - security-advisory squigglies on vulnerable package.json dependencies.
+ *
+ * @module extensionsIntegrated/JSONSupport/main
+ */
+define(function (require, exports, module) {
+
+
+ const AppInit = require("utils/AppInit"),
+ CodeHintManager = require("editor/CodeHintManager"),
+ JsonLsp = require("./JsonLsp"),
+ NpmHints = require("./NpmHints"),
+ NpmHover = require("./NpmHover"),
+ VulnerabilityInspection = require("./VulnerabilityInspection");
+
+ // Above the JSON language server's schema completion (priority 1): the npm provider claims
+ // only dependency-section contexts in package.json and declines everywhere else, so schema
+ // completion still serves the rest of the file.
+ const NPM_HINTS_PRIORITY = 2;
+
+ AppInit.appReady(function () {
+ CodeHintManager.registerHintProvider(new NpmHints.NpmHints(), ["json"], NPM_HINTS_PRIORITY);
+ VulnerabilityInspection.init();
+ NpmHover.init();
+ JsonLsp.init(); // no-ops outside the desktop app
+ });
+});
diff --git a/src/extensionsIntegrated/JSONSupport/schemaAssociations.js b/src/extensionsIntegrated/JSONSupport/schemaAssociations.js
new file mode 100644
index 0000000000..28b5376119
--- /dev/null
+++ b/src/extensionsIntegrated/JSONSupport/schemaAssociations.js
@@ -0,0 +1,69 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/**
+ * Curated JSON-schema associations for well-known config files, pushed to the JSON language
+ * server as the `json.schemas` setting. The server downloads the schema URLs itself (http/https
+ * are in its default handledSchemaProtocols) and caches them for the session.
+ *
+ * This is a hand-picked subset of the schemastore.org catalog covering the files web developers
+ * meet daily. Fetching the full catalog (https://www.schemastore.org/api/json/catalog.json) and
+ * merging it in is a possible future enhancement - kept out for now to avoid a network dependency
+ * at server start.
+ *
+ * Shape: LSP `json.schemas` entries - { fileMatch: string[], url: string }.
+ *
+ * @module extensionsIntegrated/JSONSupport/schemaAssociations
+ */
+define(function (require, exports, module) {
+
+
+ const SCHEMA_ASSOCIATIONS = [
+ { fileMatch: ["package.json"], url: "https://json.schemastore.org/package.json" },
+ { fileMatch: ["tsconfig.json", "tsconfig.*.json"], url: "https://json.schemastore.org/tsconfig" },
+ { fileMatch: ["jsconfig.json", "jsconfig.*.json"], url: "https://json.schemastore.org/jsconfig" },
+ { fileMatch: [".eslintrc", ".eslintrc.json"], url: "https://json.schemastore.org/eslintrc" },
+ {
+ fileMatch: [".babelrc", ".babelrc.json", "babel.config.json"],
+ url: "https://json.schemastore.org/babelrc"
+ },
+ { fileMatch: [".prettierrc", ".prettierrc.json"], url: "https://json.schemastore.org/prettierrc" },
+ { fileMatch: [".stylelintrc", ".stylelintrc.json"], url: "https://json.schemastore.org/stylelintrc" },
+ { fileMatch: ["composer.json"], url: "https://json.schemastore.org/composer" },
+ { fileMatch: ["bower.json"], url: "https://json.schemastore.org/bower" },
+ { fileMatch: [".bowerrc"], url: "https://json.schemastore.org/bowerrc" },
+ { fileMatch: [".jshintrc"], url: "https://json.schemastore.org/jshintrc" },
+ {
+ fileMatch: ["manifest.json", "*.webmanifest"],
+ url: "https://json.schemastore.org/web-manifest-combined.json"
+ },
+ { fileMatch: ["lerna.json"], url: "https://json.schemastore.org/lerna" },
+ { fileMatch: ["turbo.json"], url: "https://json.schemastore.org/turbo.json" },
+ { fileMatch: ["nx.json"], url: "https://json.schemastore.org/nx" },
+ { fileMatch: ["firebase.json"], url: "https://json.schemastore.org/firebase" },
+ { fileMatch: ["vercel.json"], url: "https://json.schemastore.org/vercel" },
+ {
+ fileMatch: ["renovate.json", ".renovaterc", ".renovaterc.json"],
+ url: "https://docs.renovatebot.com/renovate-schema.json"
+ }
+ ];
+
+ exports.SCHEMA_ASSOCIATIONS = SCHEMA_ASSOCIATIONS;
+});
diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js
index 751353f9d0..f8c3dfc4bb 100644
--- a/src/extensionsIntegrated/loader.js
+++ b/src/extensionsIntegrated/loader.js
@@ -46,5 +46,6 @@ define(function (require, exports, module) {
require("./CustomSnippets/main");
require("./CollapseFolders/main");
require("./Terminal/main");
+ require("./JSONSupport/main");
require("./pro-loader");
});
diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js
index f8b0f1ee28..1294dd26a3 100644
--- a/src/languageTools/DefaultProviders.js
+++ b/src/languageTools/DefaultProviders.js
@@ -1258,5 +1258,10 @@ define(function (require, exports, module) {
exports.LintingProvider = LintingProvider;
exports.ReferencesProvider = ReferencesProvider;
exports.serverRespToSearchModelFormat = serverRespToSearchModelFormat;
+ // Generic side-docs popup for code-hint lists: any hint provider's onHighlight can show
+ // supplementary docs beside the list (the LSP providers' own docs use the same surface).
+ // `html` is TRUSTED - escape untrusted parts before calling.
+ exports.showHintDocPopup = _showDocPopup;
+ exports.hideHintDocPopup = _hideDocPopup;
exports._docPopupHtml = _docPopupHtml; // exposed for unit tests
});
diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js
index 6b286db83e..f4bf7c6492 100644
--- a/src/languageTools/LSPClient.js
+++ b/src/languageTools/LSPClient.js
@@ -290,7 +290,17 @@ define(function (require, exports, module) {
* @return {boolean}
*/
LanguageClient.prototype.servesDocument = function (editor) {
- return !!(editor && this.languages.indexOf(editor.document.getLanguage().getId()) !== -1);
+ if (!editor || this.languages.indexOf(editor.document.getLanguage().getId()) === -1) {
+ return false;
+ }
+ // Optional per-file opt-out (config.documentFilter): lets a server decline specific files
+ // of a language it otherwise serves, so lower-priority specialised providers can win there
+ // (e.g. the JSON server yields Phoenix preference files to PrefsCodeHints).
+ if (this.config && typeof this.config.documentFilter === "function" &&
+ !this.config.documentFilter(editor.document.file.fullPath)) {
+ return false;
+ }
+ return true;
};
LanguageClient.prototype._request = function (method, params) {
@@ -305,9 +315,26 @@ define(function (require, exports, module) {
return getConnector().then(function (conn) {
return conn.execPeer("sendNotification", { serverId: serverId, method: method, params: params });
}).catch(function (err) {
- // Notifications are best-effort (the server may be restarting). Don't let it become an
- // unhandled rejection, but still surface it as a warning so we are not blind.
+ // Log AND re-throw: callers must be able to react to a lost notification (DocumentSync
+ // flags a full resync when a didChange never reached the server - swallowing here would
+ // leave the server's copy silently divergent). Fire-and-forget callers attach their own
+ // no-op catch.
console.warn("[LSP] notification '" + method + "' failed:", err && (err.message || err));
+ throw err;
+ });
+ };
+
+ /**
+ * Send an arbitrary LSP notification to this client's server (e.g. a feature module pushing
+ * `workspace/didChangeConfiguration` after start). Best-effort: failures are logged, never
+ * thrown.
+ * @param {string} method - LSP notification method name
+ * @param {Object} params - notification params
+ * @return {Promise}
+ */
+ LanguageClient.prototype.sendCustomNotification = function (method, params) {
+ return this._notify(method, params).catch(function () {
+ // already logged in _notify; custom notifications are fire-and-forget
});
};
@@ -327,7 +354,10 @@ define(function (require, exports, module) {
});
};
LanguageClient.prototype.notifyDidClose = function (uri) {
- return this._notify("textDocument/didClose", { textDocument: { uri: uri } });
+ return this._notify("textDocument/didClose", { textDocument: { uri: uri } }).catch(function () {
+ // ignorable: if the close never arrived the server is usually gone/restarting anyway,
+ // and (re)start resyncs documents from scratch
+ });
};
function _positionOf(cursorPos) {
@@ -600,7 +630,7 @@ define(function (require, exports, module) {
return root ? root.fullPath : null;
}
- function _clientCapabilities() {
+ function _clientCapabilities(config) {
return {
textDocument: {
synchronization: {
@@ -612,7 +642,11 @@ define(function (require, exports, module) {
completion: {
dynamicRegistration: false,
completionItem: {
- snippetSupport: false,
+ // Off by default: our hint insertion is plain text. A server config can opt
+ // in (completionSnippetSupport) when the server refuses to offer completion
+ // without it - vscode-json-language-server does - and the CodeHintsProvider
+ // strips snippet placeholders on insert for such items.
+ snippetSupport: !!(config && config.completionSnippetSupport),
documentationFormat: ["markdown", "plaintext"],
// We render labelDetails.detail/description (e.g. the source module of an
// auto-import) so otherwise-identical labels are distinguishable - see the
@@ -679,7 +713,7 @@ define(function (require, exports, module) {
locale: _uiLocale(),
rootUri: rootUri,
workspaceFolders: rootUri ? [{ uri: rootUri, name: rootName }] : null,
- capabilities: _clientCapabilities(),
+ capabilities: _clientCapabilities(config),
initializationOptions: config.initializationOptions || {}
}
});
@@ -760,6 +794,14 @@ define(function (require, exports, module) {
* When omitted, a generic default is used (identifier chars + the server's non-whitespace
* triggerCharacters). Explicit invocation (Ctrl-Space) always shows hints regardless.
* @param {function():string} [config.rootUriProvider] - returns the workspace root VFS path
+ * @param {function(string):boolean} [config.documentFilter] - per-file opt-out: given a file's
+ * full path, return false to make this server decline the file even though its language
+ * matches (e.g. the JSON server yields Phoenix pref files to PrefsCodeHints).
+ * @param {boolean} [config.completionSnippetSupport] - advertise snippet support in the client
+ * completion capability. Only for servers that refuse to offer completion without it
+ * (vscode-json-language-server); snippet placeholders are stripped on insert.
+ * @param {function(Array):Array} [config.filterDiagnostics] - server-specific post-filter for
+ * published diagnostics
* @return {Promise} the client, or null if it could not be started
*/
async function registerLanguageServer(config) {
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index 72d075a6ff..0827bf0bdb 100644
--- a/src/nls/root/strings.js
+++ b/src/nls/root/strings.js
@@ -1800,6 +1800,17 @@ define({
"CODE_INTEL_PANEL_TEXT": "Project-wide code intelligence is off. Enable it for Find Usages, Rename, and Go to Definition across every file — adds a jsconfig.json to the project root.",
"CODE_INTEL_PANEL_ENABLE": "Enable",
"CODE_INTEL_PANEL_DISMISS": "Dismiss",
+ // JSON / package.json intelligence (JSONSupport)
+ "NPM_VULN_PROVIDER_NAME": "npm Security Advisories",
+ "NPM_VULN_MESSAGE": "{0}@{1} is vulnerable: {2} ({3} severity)",
+ "NPM_SEVERITY_CRITICAL": "critical",
+ "NPM_SEVERITY_HIGH": "high",
+ "NPM_SEVERITY_MODERATE": "moderate",
+ "NPM_SEVERITY_LOW": "low",
+ "NPM_HINT_LATEST_MINOR": "latest, minor updates ok",
+ "NPM_HINT_LATEST_PATCH": "latest, patch updates only",
+ "NPM_HOVER_HOMEPAGE": "Open homepage",
+ "NPM_HOVER_VIEW_DOCS": "View docs",
// Code Intelligence config settings panel (shown when a root ts/jsconfig is the active file)
"CODE_INTEL_CFG_TITLE": "Code Intelligence",
"CODE_INTEL_CFG_ALL_OPTIONS": "All options…",
diff --git a/src/styles/brackets.less b/src/styles/brackets.less
index 4992f0bdfc..79021c4d01 100644
--- a/src/styles/brackets.less
+++ b/src/styles/brackets.less
@@ -2229,7 +2229,7 @@ a, img {
align-self: stretch;
max-width: 520px;
padding: 2px 4px;
- font-size: 12.5px;
+ font-size: 13.5px;
line-height: 1.5;
color: @bc-text;
word-wrap: break-word;
@@ -2281,7 +2281,7 @@ a, img {
// Inline code: parameter names, types, identifiers.
code {
font-family: 'SourceCodePro', 'SF Mono', Menlo, Consolas, monospace;
- font-size: 11.5px;
+ font-size: 12.5px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
@@ -2454,7 +2454,7 @@ a, img {
overflow-y: auto;
overflow-x: hidden;
padding: 8px 12px;
- font-size: 12.5px;
+ font-size: 13px;
line-height: 1.5;
word-wrap: break-word;
@@ -2483,6 +2483,19 @@ a, img {
p:first-child { margin-top: 0; }
p:last-child { margin-bottom: 0; }
+ // Headings (e.g. the package-name header in npm hint docs): kill the browser's large default
+ // margins - a first-child heading otherwise adds its top margin to the popup's own padding and
+ // the spacing reads top-heavy vs the bottom.
+ h1, h2, h3, h4, h5, h6 {
+ margin: 8px 0 5px;
+ font-size: 13px;
+ line-height: 1.4;
+ }
+ h1:first-child, h2:first-child, h3:first-child,
+ h4:first-child, h5:first-child, h6:first-child {
+ margin-top: 0;
+ }
+
// Code blocks - the top signature and any fenced blocks inside the docs - stay cohesive with the
// popup surface rather than being a separate-coloured panel: the fill is a faint tint *derived
// from* the surface (a low-opacity black/white wash, so it reads the same in both themes) with a
@@ -2637,6 +2650,34 @@ span.brackets-hints-with-type-details {
color: #6495ed !important;
}
+// npm dependency hover (JSONSupport): links row - homepage left, "View docs" pinned bottom-right.
+// (font size comes from the shared .lsp-hover-quickview base)
+.npm-hover-quickview .npm-hover-links {
+ display: flex;
+ align-items: baseline;
+ gap: 14px;
+ .npm-hover-docs-link {
+ margin-left: auto;
+ }
+}
+
+// npm package hints (JSONSupport): the registry description renders as ONE ellipsized line after
+// the package name - long descriptions read in the side docs popup instead (see NpmHints
+// onHighlight). Overrides the generic block layout above, which wraps long text under the name
+// and stretches the list arbitrarily wide.
+.npm-hint .hint-description,
+.highlight .npm-hint .hint-description {
+ display: inline-block;
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ vertical-align: bottom;
+ margin-left: 12px;
+ font-size: 0.92em;
+ opacity: 0.8;
+}
+
.hint-doc {
display: none;
padding-right: 10px !important;
diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js
index 9a1a88b27c..e0e8405aa2 100644
--- a/test/UnitTestSuite.js
+++ b/test/UnitTestSuite.js
@@ -136,6 +136,8 @@ define(function (require, exports, module) {
require("spec/Extn-JSHint-integ-test");
require("spec/Extn-ESLint-integ-test");
require("spec/Extn-CSSColorPreview-integ-test");
+ require("spec/Extn-JSONSupport-test");
+ require("spec/Extn-JSONSupport-integ-test");
require("spec/Extn-CollapseFolders-integ-test");
require("spec/Extn-Tabbar-integ-test");
require("spec/Extn-CustomSnippets-test");
diff --git a/test/spec/Extn-JSONSupport-integ-test.js b/test/spec/Extn-JSONSupport-integ-test.js
new file mode 100644
index 0000000000..749fc805b3
--- /dev/null
+++ b/test/spec/Extn-JSONSupport-integ-test.js
@@ -0,0 +1,116 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/*global describe, it, expect, beforeAll, afterAll, awaitsFor, awaitsForDone */
+
+define(function (require, exports, module) {
+
+
+ if (!Phoenix.isNativeApp) {
+ // The JSON language server is a node process - desktop builds only.
+ return;
+ }
+
+ const SpecRunnerUtils = require("spec/SpecRunnerUtils");
+
+ describe("integration:JSON LSP", function () {
+ const testFolder = SpecRunnerUtils.getTestPath("/spec/JSONSupport-test-files");
+ let testWindow,
+ $,
+ CommandManager,
+ Commands;
+
+ beforeAll(async function () {
+ testWindow = await SpecRunnerUtils.createTestWindowAndRun();
+ $ = testWindow.$;
+ CommandManager = testWindow.brackets.test.CommandManager;
+ Commands = testWindow.brackets.test.Commands;
+ await SpecRunnerUtils.loadProjectInTestWindow(testFolder);
+ }, 100000); // cold embedded-window boot can exceed 30s (same budget as the TS LSP suite)
+
+ afterAll(async function () {
+ testWindow = null;
+ await SpecRunnerUtils.closeTestWindow();
+ }, 30000);
+
+ async function _openFile(fileName) {
+ await awaitsForDone(
+ CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN,
+ { fullPath: testFolder + "/" + fileName }));
+ }
+
+ function _problemsText() {
+ return $("#problems-panel").text();
+ }
+
+ it("should flag JSON syntax errors via the language server", async function () {
+ // First spec doubles as the server warm-up (spawn + initialize), hence the
+ // larger budget. Syntax validation needs no schema download - network-free.
+ await _openFile("broken.json");
+ await awaitsFor(function () {
+ return /json \(/.test(_problemsText());
+ }, "JSON server syntax diagnostics in the problems panel", 90000);
+ }, 120000);
+
+ it("should validate against a pushed schema association (inline schema, no network)", async function () {
+ const JsonLsp = testWindow.require("extensionsIntegrated/JSONSupport/JsonLsp");
+ JsonLsp._setTestSchemaAssociations([{
+ fileMatch: ["appsettings.json"],
+ schema: {
+ type: "object",
+ properties: {
+ mode: { type: "string", enum: ["dev", "prod"] }
+ }
+ }
+ }]);
+ await _openFile("appsettings.json");
+ await awaitsFor(function () {
+ // the fixture's "invalid-mode" violates the enum
+ return /not accepted|allowed values|invalid-mode|dev/i.test(_problemsText());
+ }, "schema enum diagnostic for appsettings.json", 30000);
+ JsonLsp._setTestSchemaAssociations(null);
+ }, 45000);
+
+ it("should squiggle vulnerable dependencies using advisory data", async function () {
+ const NpmRegistry = testWindow.require("extensionsIntegrated/JSONSupport/NpmRegistry");
+ NpmRegistry._setFetcherForTests(function (url, options) {
+ if (url.indexOf("/security/advisories/bulk") !== -1) {
+ return Promise.resolve({
+ lodash: [{
+ id: 1, url: "https://example.test/advisory",
+ title: "Fixture Prototype Pollution", severity: "high",
+ vulnerable_versions: "<4.18.0"
+ }]
+ });
+ }
+ // abbreviated version doc for lodash
+ return Promise.resolve({
+ "dist-tags": { latest: "4.17.21" },
+ versions: { "4.17.19": {}, "4.17.21": {} }
+ });
+ });
+ await _openFile("package.json");
+ await awaitsFor(function () {
+ return _problemsText().indexOf("Fixture Prototype Pollution") !== -1;
+ }, "vulnerability advisory in the problems panel", 30000);
+ expect(_problemsText()).toContain("lodash@4.17.21");
+ }, 45000);
+ });
+});
diff --git a/test/spec/Extn-JSONSupport-test.js b/test/spec/Extn-JSONSupport-test.js
new file mode 100644
index 0000000000..9c9842c1b9
--- /dev/null
+++ b/test/spec/Extn-JSONSupport-test.js
@@ -0,0 +1,326 @@
+/*
+ * GNU AGPL-3.0 License
+ *
+ * Copyright (c) 2021 - present core.ai . All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+ * for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
+ *
+ */
+
+/*global describe, it, expect, beforeEach, afterEach, awaitsFor, jasmine */
+
+define(function (require, exports, module) {
+
+
+ const SpecRunnerUtils = require("spec/SpecRunnerUtils"),
+ CodeInspection = require("language/CodeInspection"),
+ NpmRegistry = require("extensionsIntegrated/JSONSupport/NpmRegistry"),
+ NpmHints = require("extensionsIntegrated/JSONSupport/NpmHints"),
+ NpmHover = require("extensionsIntegrated/JSONSupport/NpmHover"),
+ VulnerabilityInspection = require("extensionsIntegrated/JSONSupport/VulnerabilityInspection");
+
+ describe("unit:JSONSupport npm intelligence", function () {
+
+ describe("VulnerabilityInspection._findDependencyRanges", function () {
+ const find = VulnerabilityInspection._findDependencyRanges;
+
+ it("should locate entries in dependencies and devDependencies", function () {
+ const text = '{\n' +
+ ' "dependencies": {\n' +
+ ' "lodash": "^4.17.19",\n' +
+ ' "express": "~4.18.0"\n' +
+ ' },\n' +
+ ' "devDependencies": {\n' +
+ ' "jasmine": "5.0.0"\n' +
+ ' }\n' +
+ '}\n';
+ const entries = find(text);
+ expect(entries.length).toBe(3);
+ expect(entries[0]).toEqual(jasmine.objectContaining({
+ name: "lodash", range: "^4.17.19", section: "dependencies"
+ }));
+ expect(entries[0].pos).toEqual({ line: 2, ch: 8 });
+ expect(entries[0].endPos).toEqual({ line: 2, ch: 28 });
+ expect(entries[2]).toEqual(jasmine.objectContaining({
+ name: "jasmine", range: "5.0.0", section: "devDependencies"
+ }));
+ });
+
+ it("should handle the same package in two sections and scoped names", function () {
+ const text = '{"dependencies": {"@types/node": "^20.0.0", "a": "1.0.0"},' +
+ ' "peerDependencies": {"a": "2.0.0"}}';
+ const entries = find(text);
+ expect(entries.length).toBe(3);
+ expect(entries[0].name).toBe("@types/node");
+ expect(entries[1].pos.line).toBe(0); // single-line JSON maps to line 0
+ expect(entries[2]).toEqual(jasmine.objectContaining({
+ name: "a", range: "2.0.0", section: "peerDependencies"
+ }));
+ });
+
+ it("should not scan outside the dependency sections or crash on missing ones", function () {
+ const text = '{"scripts": {"build": "tsc"}, "dependencies": {"x": "1.0.0"}}';
+ const entries = find(text);
+ expect(entries.length).toBe(1);
+ expect(entries[0].name).toBe("x");
+ expect(find('{"name": "no-deps"}').length).toBe(0);
+ });
+
+ it("should skip past escaped quotes inside strings while brace matching", function () {
+ const text = '{"dependencies": {"weird\\"pkg": "1.0.0", "ok": "2.0.0"}}';
+ const entries = find(text);
+ expect(entries.length).toBe(2);
+ expect(entries[1].name).toBe("ok");
+ });
+ });
+
+ describe("VulnerabilityInspection severity mapping and error building", function () {
+ it("should map severities to CodeInspection types", function () {
+ expect(VulnerabilityInspection._severityToType("critical")).toBe(CodeInspection.Type.ERROR);
+ expect(VulnerabilityInspection._severityToType("high")).toBe(CodeInspection.Type.ERROR);
+ expect(VulnerabilityInspection._severityToType("moderate")).toBe(CodeInspection.Type.WARNING);
+ expect(VulnerabilityInspection._severityToType("low")).toBe(CodeInspection.Type.META);
+ });
+
+ it("should dedup advisories by id, sort by severity and cap the count", function () {
+ const entry = { name: "p", pos: { line: 1, ch: 0 }, endPos: { line: 1, ch: 10 } };
+ const advisories = [
+ { id: 1, title: "low one", severity: "low" },
+ { id: 2, title: "crit", severity: "critical" },
+ { id: 2, title: "crit dup", severity: "critical" },
+ { id: 3, title: "mod", severity: "moderate" },
+ { id: 4, title: "high", severity: "high" },
+ { id: 5, title: "low two", severity: "low" }
+ ];
+ const errors = VulnerabilityInspection._errorsForEntry(entry, "1.0.0", advisories);
+ expect(errors.length).toBe(3); // capped
+ expect(errors[0].message).toContain("crit");
+ expect(errors[0].message).toContain("p@1.0.0");
+ expect(errors[1].message).toContain("high");
+ expect(errors[2].message).toContain("mod");
+ expect(errors[0].pos).toEqual(entry.pos);
+ expect(errors[0].endPos).toEqual(entry.endPos);
+ });
+ });
+
+ describe("NpmHover npm page url", function () {
+ it("should link the declared version's doc page when the range anchors one", function () {
+ expect(NpmHover._npmPageUrl("vite", "^5.4.11"))
+ .toBe("https://www.npmjs.com/package/vite/v/5.4.11");
+ expect(NpmHover._npmPageUrl("vite", "~7.2.3"))
+ .toBe("https://www.npmjs.com/package/vite/v/7.2.3");
+ expect(NpmHover._npmPageUrl("vite", "7.2.3"))
+ .toBe("https://www.npmjs.com/package/vite/v/7.2.3");
+ expect(NpmHover._npmPageUrl("@types/node", ">=20.1.0"))
+ .toBe("https://www.npmjs.com/package/@types/node/v/20.1.0");
+ });
+
+ it("should fall back to the package page for anchorless ranges", function () {
+ expect(NpmHover._npmPageUrl("vite", "*")).toBe("https://www.npmjs.com/package/vite");
+ expect(NpmHover._npmPageUrl("vite", "")).toBe("https://www.npmjs.com/package/vite");
+ expect(NpmHover._npmPageUrl("vite", "latest")).toBe("https://www.npmjs.com/package/vite");
+ });
+ });
+
+ describe("NpmRegistry with injected fetcher", function () {
+ let fetchCalls;
+
+ function fakeFetcher(responder) {
+ fetchCalls = [];
+ NpmRegistry._setFetcherForTests(function (url, options) {
+ fetchCalls.push({ url: url, options: options || {} });
+ return Promise.resolve(responder(url, options));
+ });
+ }
+
+ afterEach(function () {
+ // restore a fetcher that fails loudly if any spec forgets to inject
+ NpmRegistry._setFetcherForTests(function () {
+ return Promise.reject(new Error("no fetcher injected"));
+ });
+ });
+
+ it("should map search results and serve repeats from cache", async function () {
+ fakeFetcher(function () {
+ return { objects: [
+ { package: { name: "lodash", version: "4.18.1", description: "utils" } },
+ { package: { name: "lodash-es", version: "4.18.1" } }
+ ] };
+ });
+ const first = await NpmRegistry.searchPackages("loda");
+ expect(first.length).toBe(2);
+ expect(first[0]).toEqual({ name: "lodash", version: "4.18.1", description: "utils" });
+ expect(first[1].description).toBe("");
+ expect(fetchCalls[0].url).toContain("search?text=loda&size=");
+
+ await NpmRegistry.searchPackages("loda");
+ expect(fetchCalls.length).toBe(1); // cached
+ });
+
+ it("should fetch abbreviated version docs and encode scoped names", async function () {
+ fakeFetcher(function () {
+ return { "dist-tags": { latest: "20.1.0" }, versions: { "20.0.0": {}, "20.1.0": {} } };
+ });
+ const info = await NpmRegistry.getVersions("@types/node");
+ expect(info.latest).toBe("20.1.0");
+ expect(info.versions).toEqual(["20.0.0", "20.1.0"]);
+ expect(fetchCalls[0].url).toBe("https://registry.npmjs.org/@types%2Fnode");
+ expect(fetchCalls[0].options.headers.Accept).toBe("application/vnd.npm.install-v1+json");
+ });
+
+ it("should build the bulk advisory body, cache results and negative results", async function () {
+ fakeFetcher(function () {
+ return { lodash: [{ id: 9, title: "bad", severity: "high" }] };
+ });
+ const pairs = [
+ { name: "lodash", version: "4.17.19" },
+ { name: "safe-pkg", version: "1.0.0" }
+ ];
+ const result = await NpmRegistry.getAdvisoriesBulk(pairs);
+ expect(result.lodash.length).toBe(1);
+ expect(result["safe-pkg"]).toBeUndefined();
+ expect(JSON.parse(fetchCalls[0].options.body)).toEqual({
+ lodash: ["4.17.19"], "safe-pkg": ["1.0.0"]
+ });
+
+ const again = await NpmRegistry.getAdvisoriesBulk(pairs);
+ expect(fetchCalls.length).toBe(1); // both hits AND misses cached
+ expect(again.lodash.length).toBe(1);
+ });
+ });
+
+ describe("NpmHints context detection", function () {
+ let mockEditor, testEditor, testDocument, provider;
+ const content = '{\n' +
+ ' "name": "fixture",\n' +
+ ' "dependencies": {\n' +
+ ' "lodash": "^4.17.19"\n' +
+ ' }\n' +
+ '}\n';
+
+ beforeEach(function () {
+ mockEditor = SpecRunnerUtils.createMockEditor(content, "json");
+ testEditor = mockEditor.editor;
+ testDocument = mockEditor.doc;
+ testDocument.file._name = "package.json";
+ provider = new NpmHints.NpmHints();
+ });
+
+ afterEach(function () {
+ testEditor.destroy();
+ testDocument = null;
+ });
+
+ it("should claim a key inside dependencies (name mode)", function () {
+ testEditor.setCursorPos({ line: 3, ch: 11 }); // inside "lodash" key
+ expect(provider.hasHints(testEditor, null)).toBe(true);
+ });
+
+ it("should claim a value inside dependencies (version mode)", function () {
+ testEditor.setCursorPos({ line: 3, ch: 21 }); // inside "^4.17.19" value
+ expect(provider.hasHints(testEditor, null)).toBe(true);
+ });
+
+ it("should decline top-level keys so schema completion serves them", function () {
+ testEditor.setCursorPos({ line: 1, ch: 8 }); // inside "name" key
+ expect(provider.hasHints(testEditor, null)).toBe(false);
+ });
+
+ it("should decline files that are not package.json", function () {
+ testDocument.file._name = "config.json";
+ testEditor.setCursorPos({ line: 3, ch: 11 });
+ expect(provider.hasHints(testEditor, null)).toBe(false);
+ });
+
+ it("should serve version hints newest-first with range shortcuts on top", async function () {
+ NpmRegistry._setFetcherForTests(function () {
+ return Promise.resolve({
+ "dist-tags": { latest: "4.18.1" },
+ versions: { "4.17.19": {}, "4.18.1": {}, "4.2.0": {} }
+ });
+ });
+ testEditor.setCursorPos({ line: 3, ch: 20 }); // just inside the value quote
+ expect(provider.hasHints(testEditor, null)).toBe(true);
+ const deferred = provider.getHints(null);
+ let hintObj = null;
+ deferred.done(function (result) { hintObj = result; });
+ await awaitsFor(function () { return !!hintObj; }, "version hints to resolve");
+ const texts = hintObj.hints.map(function ($item) {
+ return $item.find(".hint-obj").text();
+ });
+ expect(texts[0]).toBe("^4.18.1");
+ expect(texts[1]).toBe("~4.18.1");
+ expect(texts.indexOf("4.18.1")).toBeLessThan(texts.indexOf("4.17.19"));
+ expect(texts.indexOf("4.17.19")).toBeLessThan(texts.indexOf("4.2.0"));
+ });
+
+ async function _versionHintTexts(provider, cursorCh) {
+ testEditor.setCursorPos({ line: 3, ch: cursorCh });
+ expect(provider.hasHints(testEditor, null)).toBe(true);
+ const deferred = provider.getHints(null);
+ let hintObj = null;
+ deferred.done(function (result) { hintObj = result; });
+ await awaitsFor(function () { return !!hintObj; }, "version hints to resolve");
+ return hintObj.hints.map(function ($item) {
+ return $item.find(".hint-obj").text();
+ });
+ }
+
+ it("should match a typed prefix against the FULL version list, not just the newest", async function () {
+ // many 7.x versions on top; the 5.x train is old. Typing "5" must surface 5.x.
+ const versions = {};
+ for (let i = 0; i < 60; i++) {
+ versions["7." + i + ".0"] = {};
+ }
+ versions["5.4.11"] = {};
+ NpmRegistry._setFetcherForTests(function () {
+ return Promise.resolve({ "dist-tags": { latest: "7.59.0" }, versions: versions });
+ });
+ // value is "^4.17.19": cursor after `"^` is ch 20; the doc token query becomes "^".
+ // Type-sim: place cursor after the caret and 5 -> query "^5" via ch offset math is
+ // brittle here, so instead exercise the provider path directly with a mock editor
+ // whose value starts with ^5: reuse content by moving the cursor inside "^4" and
+ // relying on the fallback assertion below for the no-match case; for the 5-prefix
+ // case, use a fresh editor.
+ testEditor.destroy();
+ mockEditor = SpecRunnerUtils.createMockEditor(
+ '{\n "name": "x",\n "dependencies": {\n "vite": "^5"\n }\n}\n', "json");
+ testEditor = mockEditor.editor;
+ testDocument = mockEditor.doc;
+ testDocument.file._name = "package.json";
+ const texts = await _versionHintTexts(provider, 19); // inside "^5"
+ expect(texts.indexOf("5.4.11")).not.toBe(-1);
+ expect(texts.indexOf("7.59.0")).toBe(-1); // filtered out by the 5-prefix
+ });
+
+ it("should fall back to newest versions when the typed prefix matches nothing", async function () {
+ NpmRegistry._setFetcherForTests(function () {
+ return Promise.resolve({
+ "dist-tags": { latest: "7.2.0" },
+ versions: { "7.1.0": {}, "7.2.0": {} }
+ });
+ });
+ testEditor.destroy();
+ mockEditor = SpecRunnerUtils.createMockEditor(
+ '{\n "name": "x",\n "dependencies": {\n "vite": "^9"\n }\n}\n', "json");
+ testEditor = mockEditor.editor;
+ testDocument = mockEditor.doc;
+ testDocument.file._name = "package.json";
+ const texts = await _versionHintTexts(provider, 19); // inside "^9" - no 9.x exists
+ expect(texts.length).toBeGreaterThan(0);
+ expect(texts.indexOf("7.2.0")).not.toBe(-1);
+ });
+ });
+ });
+});
diff --git a/test/spec/JSONSupport-test-files/appsettings.json b/test/spec/JSONSupport-test-files/appsettings.json
new file mode 100644
index 0000000000..47c41623cc
--- /dev/null
+++ b/test/spec/JSONSupport-test-files/appsettings.json
@@ -0,0 +1,3 @@
+{
+ "mode": "invalid-mode"
+}
diff --git a/test/spec/JSONSupport-test-files/broken.json b/test/spec/JSONSupport-test-files/broken.json
new file mode 100644
index 0000000000..a2c2ed1fc7
--- /dev/null
+++ b/test/spec/JSONSupport-test-files/broken.json
@@ -0,0 +1,3 @@
+{
+ "a": 1,,
+}
diff --git a/test/spec/JSONSupport-test-files/package.json b/test/spec/JSONSupport-test-files/package.json
new file mode 100644
index 0000000000..1d18d4e44d
--- /dev/null
+++ b/test/spec/JSONSupport-test-files/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "jsonsupport-fixture",
+ "version": "1.0.0",
+ "dependencies": {
+ "lodash": "^4.17.19"
+ }
+}