Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,9 @@ Fixes
Fixes

- Do not open the output channel automatically

## [Unreleased]

Feature

- Added a JSON schema for the `.vscode/extensions*.json(c)` configuration files, providing validation, autocompletion and hover documentation (#5)
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ Create a file `.vscode/extensionsVersionCheck.json`, or if you need comments in
* There will be a maximum of 1 notification per extension
* If you save it as `.json`, make sure to remove all the comments

### 🆕 Validation & IntelliSense

This extension ships a **JSON schema** for the version check file, so you get validation, autocompletion and hover documentation while editing it — the same experience VSCode provides for the original `.vscode/extensions.json`.

It is applied automatically (no configuration needed) to every file the extension reads, which is `.vscode/extensions*.json` and `.vscode/extensions*.jsonc`:

* Unknown properties and wrong types are reported
* Entries are checked against the `${publisher}.${name}[@versionRange]` format
* Snippets help you to add a new entry, with or without a version range

> ℹ️ The original `.vscode/extensions.json` is deliberately **excluded**, because VSCode already provides its own schema for that file. Note that VSCode's schema does not know about version ranges, so it will report `publisher.name@1.0.0` entries as invalid. This is another reason to keep versioned entries in `.vscode/extensionsVersionCheck.json(c)`.

### 🆕 Logs

You can check the logs if you need more details, what is happening during the checks: `VSCode -> OUTPUT -> Unwanted extensions`
Expand All @@ -110,7 +122,8 @@ This file does not support version numbers. See above for details.

### Version numbers

Version numbers are only supported within the `.vscode/extensionsVersionCheck.json` (or `.jsonc`) file.
Version numbers are only supported within the `.vscode/extensionsVersionCheck.json` (or `.jsonc`) file.
Those files are validated by the JSON schema shipped with this extension, see [Validation & IntelliSense](#-validation--intellisense).

### Workspaces / Multi-root workspaces

Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@
"command": "vscode-unwanted-extensions.checkPackages",
"title": "Check for unwanted extensions"
}
],
"jsonValidation": [
{
"fileMatch": [
"**/.vscode/extensions*.json",
"**/.vscode/extensions*.jsonc",
"!**/.vscode/extensions.json"
],
"url": "./schemas/extensionsVersionCheck.schema.json"
}
]
},
"scripts": {
Expand Down
51 changes: 51 additions & 0 deletions schemas/extensionsVersionCheck.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Unwanted Extensions",
"markdownDescription": "Configuration for the [Unwanted Extensions](https://marketplace.visualstudio.com/items?itemName=Soulcode.vscode-unwanted-extensions) extension. Unlike `.vscode/extensions.json`, this file supports SemVer version ranges.",
"type": "object",
"allowComments": true,
"allowTrailingCommas": true,
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "The URL of the schema used to validate this file."
},
"recommendations": {
"type": "array",
"markdownDescription": "⚠️ Not supported by the *Unwanted Extensions* extension, and version ranges are not supported here either. Define your `recommendations` in `.vscode/extensions.json` instead, which is handled by VS Code itself.",
"items": {
"$ref": "#/definitions/extensionEntry"
},
"uniqueItems": true
},
"unwantedRecommendations": {
"type": "array",
"markdownDescription": "Extensions which should be disabled for this workspace.\n\nUse the extension identifier `${publisher}.${name}`, optionally followed by `@` and a [SemVer range](https://github.com/npm/node-semver#ranges) to only warn about matching versions.\n\nExamples:\n\n* `octref.vetur` — any installed version\n* `formulahendry.auto-complete-tag@0.1.0` — exact version\n* `formulahendry.auto-complete-tag@<0.2.0` — everything below `0.2.0`\n* `formulahendry.auto-complete-tag@0.1 - 0.9` — hyphen range\n* `formulahendry.auto-complete-tag@^0.1.0` — caret range\n\nThe same extension may be listed multiple times with different version ranges. Only one notification per extension is shown.",
"items": {
"$ref": "#/definitions/extensionEntry"
},
"uniqueItems": true
}
},
"definitions": {
"extensionEntry": {
"type": "string",
"markdownDescription": "Extension identifier in the format `${publisher}.${name}`, optionally followed by `@` and a SemVer range. Example: `formulahendry.auto-complete-tag@<0.2.0`",
"pattern": "^[a-z0-9A-Z][a-z0-9-A-Z]*\\.[a-z0-9A-Z][a-z0-9-A-Z]*(@.+)?$",
"patternErrorMessage": "Expected format '${publisher}.${name}', optionally followed by '@' and a SemVer range. Example: 'formulahendry.auto-complete-tag@<0.2.0'.",
"defaultSnippets": [
{
"label": "Extension without version range",
"description": "Warn about the extension, no matter which version is installed",
"body": "${1:publisher}.${2:name}"
},
{
"label": "Extension with version range",
"description": "Warn only if the installed version matches the SemVer range",
"body": "${1:publisher}.${2:name}@${3:<1.0.0}"
}
]
}
}
}
114 changes: 114 additions & 0 deletions src/test/unit/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as path from 'path';
import * as semver from 'semver';
import { parseConfig, parseUnwantedEntry } from '../../core';

type JsonValidation = { fileMatch: string | string[]; url: string };

const repoRoot = path.join(__dirname, '..', '..', '..');

const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const jsonValidation: JsonValidation[] = packageJson.contributes?.jsonValidation ?? [];
const contribution = jsonValidation[0];
const fileMatch = [contribution?.fileMatch ?? []].flat();

const schema = JSON.parse(fs.readFileSync(path.join(repoRoot, contribution.url), 'utf8'));
const entryPattern = new RegExp(schema.definitions.extensionEntry.pattern);

/**
* The schema is what gives the config files validation and IntelliSense in the editor.
* It is only contributed metadata - nothing loads it at runtime - so these tests keep it
* honest against the files the extension actually reads.
*/
suite('JSON schema contribution', () => {
test('the contributed schema file exists and declares the entry definition', () => {
assert.ok(contribution, 'package.json must contribute a jsonValidation entry');
assert.ok(fs.existsSync(path.join(repoRoot, contribution.url)), `${contribution.url} is missing`);
assert.ok(schema.definitions?.extensionEntry?.pattern, 'the schema must define the entry pattern');
});

// VS Code ships its own schema for ".vscode/extensions.json" and combines it with ours.
// That one rejects the "@versionRange" suffix, so we deliberately leave the file alone
// instead of adding a second set of diagnostics on top.
test('leaves the built-in extensions.json to VS Code', () => {
assert.ok(
fileMatch.includes('!**/.vscode/extensions.json'),
'the built-in ".vscode/extensions.json" must be excluded from the file match'
);
assert.strictEqual(
fileMatch[fileMatch.length - 1],
'!**/.vscode/extensions.json',
'exclusions must come last - the last matching pattern wins'
);
});

test('covers the same custom files the config discovery looks for', () => {
const discoveryGlob = fs.readFileSync(path.join(repoRoot, 'src', 'utils.ts'), 'utf8');
assert.ok(
discoveryGlob.includes("'**/.vscode/extensions*.{json,jsonc}'"),
'the discovery glob changed - the schema fileMatch has to follow'
);
for (const pattern of ['**/.vscode/extensions*.json', '**/.vscode/extensions*.jsonc']) {
assert.ok(fileMatch.includes(pattern), `fileMatch must contain "${pattern}"`);
}
});
});

suite('JSON schema entry pattern', () => {
test('accepts extension ids with and without a version range', () => {
const valid = [
'octref.vetur',
'Soulcode.vscode-unwanted-extensions',
'vscode.typescript-language-features',
'formulahendry.auto-complete-tag@0.1.0',
'formulahendry.auto-complete-tag@>=0.1.1',
'formulahendry.auto-complete-tag@<0.2.0',
'formulahendry.auto-complete-tag@0.1 - 0.9',
'formulahendry.auto-complete-tag@0.*',
'formulahendry.auto-complete-tag@~0.1.0',
'formulahendry.auto-complete-tag@^0.1.0',
];
for (const entry of valid) {
assert.ok(entryPattern.test(entry), `"${entry}" should be accepted`);
}
});

test('rejects malformed entries', () => {
const invalid = ['missing-publisher', '.leadingDot', 'trailingDot.', 'octref.vetur@', '@0.1.0', ''];
for (const entry of invalid) {
assert.ok(!entryPattern.test(entry), `"${entry}" should be rejected`);
}
});

// The example project doubles as the integration test fixture, so a squiggle in there
// would be the first thing a user copying it runs into.
test('accepts every entry used in the example project', () => {
const fixtures = [
path.join('exampleProject', '.vscode', 'extensionsVersionCheck.jsonc'),
path.join('exampleProject', '.vscode', 'extensions.json'),
path.join('exampleProject', 'exampleSubProject', '.vscode', 'extensions.json'),
path.join('exampleProject', 'exampleProject.code-workspace'),
];

for (const fixture of fixtures) {
const config = parseConfig(fs.readFileSync(path.join(repoRoot, fixture), 'utf8'));
const entries = [...(config.recommendations ?? []), ...(config.unwantedRecommendations ?? [])];
assert.ok(entries.length > 0, `${fixture} should contain entries`);

for (const entry of entries) {
assert.ok(entryPattern.test(entry), `"${entry}" in ${fixture} is rejected by the schema`);

// The pattern cannot validate SemVer itself, so cross-check the fixtures
// against the same range handling the extension applies at runtime.
const { versionRange } = parseUnwantedEntry(entry);
if (versionRange) {
assert.ok(
semver.validRange(versionRange),
`"${versionRange}" in ${fixture} is not a valid SemVer range`
);
}
}
}
});
});
Loading