@vestra-player/sdk 1.5.25 → 1.5.26

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The capability checker: every use of a plugin API member whose capability
3
+ * the plugin's `manifest.json` does not declare. `vestra-plugin check` (and
4
+ * so `build` and `pack`) and the TypeScript language-service plugin
5
+ * (`@vestra-player/sdk/ts-plugin`) both run it; the rule is
6
+ * `capability-rules.ts` (the player's table and the SDK's requests).
7
+ *
8
+ * It is type-aware: a use is a property access, an element access with a
9
+ * literal name or a destructured property whose symbol is the SDK's
10
+ * declaration of a member in the table, however the object was reached
11
+ * (`plugin.player.seek`, `const { player } = plugin; player.seek`, a
12
+ * parameter typed `Player`, a `Panel` from `panels.mount`). Not detected:
13
+ * access with a computed name (`player[name]`), values typed `any` or
14
+ * `unknown`, plain JavaScript without types, members inherited from `View`
15
+ * (their object comes from a call that is checked) and options that need
16
+ * more (a `processed` capture, a file resolver).
17
+ *
18
+ * The TypeScript module is passed in, never imported: the CLI uses the
19
+ * SDK's own compiler, the editor its TypeScript server's.
20
+ *
21
+ * @internal
22
+ */
23
+ import type TS from "typescript";
24
+ import type { CapabilityRules } from "./capability-rules.ts";
25
+ /** @internal The module plugins import the API types from. */
26
+ export declare const SDK_MODULE = "@vestra-player/sdk";
27
+ /** @internal The diagnostic code the editor shows (and its quick fix keys on). */
28
+ export declare const CAPABILITY_DIAGNOSTIC = 95301;
29
+ /** @internal One use of a member whose capability is not declared. */
30
+ export interface CapabilityFinding {
31
+ /** The source file, as the program names it. */
32
+ readonly file: string;
33
+ /** 1-based line and column of the member's name. */
34
+ readonly line: number;
35
+ readonly column: number;
36
+ /** Offset and length of the member's name in the file. */
37
+ readonly start: number;
38
+ readonly length: number;
39
+ /** The member, `Interface.member` (`Player.seek`). */
40
+ readonly api: string;
41
+ /** The capability to declare. */
42
+ readonly capability: string;
43
+ /** The request that needs it. */
44
+ readonly method: string;
45
+ /** The declared-or-needed capability whose `requires` names it. */
46
+ readonly requiredBy?: string;
47
+ /** One line for people. */
48
+ readonly message: string;
49
+ }
50
+ /** @internal */
51
+ export interface CheckOptions {
52
+ /** The files to check (others in the program are only read for types). */
53
+ readonly files: readonly string[];
54
+ /** The manifest's `capabilities`. */
55
+ readonly capabilities: readonly string[];
56
+ /** Default: the SDK's rules. */
57
+ readonly rules?: CapabilityRules;
58
+ }
59
+ /**
60
+ * Every use, in `options.files`, of a member whose capability the manifest
61
+ * does not declare, in file and position order.
62
+ *
63
+ * @internal
64
+ */
65
+ export declare function findUndeclaredCapabilities(ts: typeof TS, program: TS.Program, options: CheckOptions): CapabilityFinding[];
66
+ /**
67
+ * Whether a file belongs to the plugin's production code, given the
68
+ * directory of its `manifest.json`: not in `tests/`, `node_modules/`,
69
+ * `dist/`, `vendor/` or a dot directory (what `vestra-plugin build` leaves
70
+ * out), and not a declaration file.
71
+ *
72
+ * @internal
73
+ */
74
+ export declare function isPluginSource(directory: string, file: string): boolean;
75
+ /**
76
+ * The edit that adds a capability to a manifest's `capabilities` array, in
77
+ * the array's own layout; `undefined` when the text has no such array.
78
+ *
79
+ * @internal
80
+ */
81
+ export declare function addCapabilityEdit(ts: typeof TS, text: string, capability: string): {
82
+ start: number;
83
+ length: number;
84
+ newText: string;
85
+ } | undefined;
@@ -0,0 +1,203 @@
1
+ import { memberNeeds, RULES } from "./capability-rules.js";
2
+ /** @internal The module plugins import the API types from. */
3
+ export const SDK_MODULE = "@vestra-player/sdk";
4
+ /** @internal The diagnostic code the editor shows (and its quick fix keys on). */
5
+ export const CAPABILITY_DIAGNOSTIC = 95_301;
6
+ const memberCache = new WeakMap();
7
+ /**
8
+ * The module `@vestra-player/sdk` as the program resolved it: from the
9
+ * first import or export of it in the program's own (non-declaration) files.
10
+ */
11
+ function sdkModule(ts, program, checker) {
12
+ for (const file of program.getSourceFiles()) {
13
+ if (file.isDeclarationFile || program.isSourceFileFromExternalLibrary(file))
14
+ continue;
15
+ for (const statement of file.statements) {
16
+ const specifier = ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)
17
+ ? statement.moduleSpecifier
18
+ : undefined;
19
+ if (specifier &&
20
+ ts.isStringLiteral(specifier) &&
21
+ specifier.text === SDK_MODULE) {
22
+ const module = checker.getSymbolAtLocation(specifier);
23
+ if (module)
24
+ return module;
25
+ }
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ /** The SDK's declarations of the members in the table. */
31
+ function sdkMembers(ts, program, rules) {
32
+ const cached = memberCache.get(program);
33
+ if (cached && rules === RULES)
34
+ return cached;
35
+ const members = new Map();
36
+ const checker = program.getTypeChecker();
37
+ const module = sdkModule(ts, program, checker);
38
+ if (module) {
39
+ const exports = new Map();
40
+ for (const symbol of checker.getExportsOfModule(module))
41
+ exports.set(symbol.name, symbol.flags & ts.SymbolFlags.Alias
42
+ ? checker.getAliasedSymbol(symbol)
43
+ : symbol);
44
+ for (const api of Object.keys(rules.apis)) {
45
+ const [head, ...path] = api.split(".");
46
+ const root = exports.get(head);
47
+ if (!root || !path.length)
48
+ continue;
49
+ let type = checker.getDeclaredTypeOfSymbol(root);
50
+ let property;
51
+ for (const name of path) {
52
+ property = type?.getProperty(name);
53
+ type = property && checker.getTypeOfSymbol(property);
54
+ }
55
+ for (const declaration of property?.declarations ?? [])
56
+ if (!members.has(declaration))
57
+ members.set(declaration, api);
58
+ }
59
+ }
60
+ if (rules === RULES)
61
+ memberCache.set(program, members);
62
+ return members;
63
+ }
64
+ /**
65
+ * Every use, in `options.files`, of a member whose capability the manifest
66
+ * does not declare, in file and position order.
67
+ *
68
+ * @internal
69
+ */
70
+ export function findUndeclaredCapabilities(ts, program, options) {
71
+ const rules = options.rules ?? RULES;
72
+ const files = options.files
73
+ .map((file) => program.getSourceFile(file))
74
+ .filter((file) => !!file);
75
+ if (!files.length)
76
+ return [];
77
+ const members = sdkMembers(ts, program, rules);
78
+ if (!members.size)
79
+ return [];
80
+ const checker = program.getTypeChecker();
81
+ const findings = [];
82
+ const memberOf = (symbol) => {
83
+ for (const declaration of symbol?.declarations ?? []) {
84
+ const api = members.get(declaration);
85
+ if (api)
86
+ return api;
87
+ }
88
+ return undefined;
89
+ };
90
+ for (const file of files) {
91
+ const report = (name, symbol) => {
92
+ const api = memberOf(symbol);
93
+ if (!api)
94
+ return;
95
+ const start = name.getStart(file);
96
+ const { line, character } = file.getLineAndCharacterOfPosition(start);
97
+ for (const need of memberNeeds(api, options.capabilities, rules) ?? []) {
98
+ if (options.capabilities.includes(need.capability))
99
+ continue;
100
+ findings.push({
101
+ file: file.fileName,
102
+ line: line + 1,
103
+ column: character + 1,
104
+ start,
105
+ length: name.getEnd() - start,
106
+ api,
107
+ capability: need.capability,
108
+ method: need.method,
109
+ ...(need.requiredBy ? { requiredBy: need.requiredBy } : {}),
110
+ message: need.requiredBy
111
+ ? `${api} needs the capability "${need.capability}" (required by "${need.requiredBy}"), which manifest.json does not declare`
112
+ : `${api} needs the capability "${need.capability}", which manifest.json does not declare`,
113
+ });
114
+ }
115
+ };
116
+ const visit = (node) => {
117
+ if (ts.isPropertyAccessExpression(node))
118
+ report(node.name, checker.getSymbolAtLocation(node.name));
119
+ else if (ts.isElementAccessExpression(node) &&
120
+ ts.isStringLiteralLike(node.argumentExpression))
121
+ report(node.argumentExpression, checker.getSymbolAtLocation(node.argumentExpression));
122
+ else if (ts.isBindingElement(node) &&
123
+ ts.isObjectBindingPattern(node.parent)) {
124
+ const name = node.propertyName ?? node.name;
125
+ const text = ts.isIdentifier(name) || ts.isStringLiteralLike(name)
126
+ ? name.text
127
+ : undefined;
128
+ if (text !== undefined)
129
+ report(name, checker.getTypeAtLocation(node.parent).getProperty(text));
130
+ }
131
+ ts.forEachChild(node, visit);
132
+ };
133
+ visit(file);
134
+ }
135
+ return findings;
136
+ }
137
+ /**
138
+ * Whether a file belongs to the plugin's production code, given the
139
+ * directory of its `manifest.json`: not in `tests/`, `node_modules/`,
140
+ * `dist/`, `vendor/` or a dot directory (what `vestra-plugin build` leaves
141
+ * out), and not a declaration file.
142
+ *
143
+ * @internal
144
+ */
145
+ export function isPluginSource(directory, file) {
146
+ const normal = (path) => path.replaceAll("\\", "/").replace(/\/$/, "");
147
+ const base = normal(directory);
148
+ const path = normal(file);
149
+ const caseless = /^[a-z]:\//i.test(path);
150
+ const same = (a, b) => caseless ? a.toLowerCase() === b.toLowerCase() : a === b;
151
+ if (!same(path.slice(0, base.length + 1), `${base}/`))
152
+ return false;
153
+ if (/\.d\.[mc]?ts$/.test(path) || !/\.[mc]?tsx?$/.test(path))
154
+ return false;
155
+ const parts = path
156
+ .slice(base.length + 1)
157
+ .split("/")
158
+ .slice(0, -1);
159
+ return !parts.some((part) => part.startsWith(".") ||
160
+ ["tests", "node_modules", "dist", "vendor"].includes(part));
161
+ }
162
+ /**
163
+ * The edit that adds a capability to a manifest's `capabilities` array, in
164
+ * the array's own layout; `undefined` when the text has no such array.
165
+ *
166
+ * @internal
167
+ */
168
+ export function addCapabilityEdit(ts, text, capability) {
169
+ const json = ts.parseJsonText("manifest.json", text);
170
+ const root = json.statements[0]?.expression;
171
+ if (!root || !ts.isObjectLiteralExpression(root))
172
+ return undefined;
173
+ const property = root.properties.find((each) => ts.isPropertyAssignment(each) &&
174
+ ts.isStringLiteral(each.name) &&
175
+ each.name.text === "capabilities");
176
+ const array = property?.initializer;
177
+ if (!array || !ts.isArrayLiteralExpression(array))
178
+ return undefined;
179
+ const quoted = JSON.stringify(capability);
180
+ const last = array.elements.at(-1);
181
+ if (!last) {
182
+ const start = array.getStart(json);
183
+ return {
184
+ start,
185
+ length: array.getEnd() - start,
186
+ newText: `[${quoted}]`,
187
+ };
188
+ }
189
+ const first = array.elements[0];
190
+ const lineStart = text.lastIndexOf("\n", first.getStart(json)) + 1;
191
+ const multiline = text
192
+ .slice(array.getStart(json), first.getStart(json))
193
+ .includes("\n");
194
+ const indent = text.slice(lineStart, first.getStart(json));
195
+ return {
196
+ start: last.getEnd(),
197
+ length: 0,
198
+ // The manifest's own line endings (CRLF on many Windows checkouts).
199
+ newText: multiline
200
+ ? `,${text.includes("\r\n") ? "\r\n" : "\n"}${indent}${quoted}`
201
+ : `, ${quoted}`,
202
+ };
203
+ }
@@ -0,0 +1,102 @@
1
+ import type { ApiRequest } from "./api-requests.ts";
2
+ /**
3
+ * What a request needs, as the player's `capabilities.json` writes it:
4
+ * `null` (nothing), a capability name, a choice by a string parameter, or a
5
+ * choice by whether the plugin declared a capability.
6
+ *
7
+ * @internal
8
+ */
9
+ export type Needs = null | string | {
10
+ readonly param: string;
11
+ readonly cases: Readonly<Record<string, string | null>>;
12
+ readonly otherwise: string | null;
13
+ } | {
14
+ readonly ifDeclared: string;
15
+ readonly then: string;
16
+ readonly otherwise: string;
17
+ };
18
+ /** @internal One RPC method of the player's table (`prefix.*` covers a prefix). */
19
+ export interface HostMethod {
20
+ readonly name: string;
21
+ readonly needs: Needs;
22
+ }
23
+ /** @internal One manifest capability of the player's table. */
24
+ export interface HostCapability {
25
+ readonly name: string;
26
+ readonly label: string;
27
+ readonly risky: boolean;
28
+ readonly requires: readonly string[];
29
+ readonly residency: string;
30
+ readonly methods: readonly HostMethod[];
31
+ }
32
+ /** @internal The player's `src/host/capabilities.json`. */
33
+ export interface HostCapabilities {
34
+ readonly about: string;
35
+ readonly apiVersion: string;
36
+ readonly capabilities: readonly HostCapability[];
37
+ readonly uncapable: readonly HostMethod[];
38
+ }
39
+ /** @internal Both tables; tests pass their own. */
40
+ export interface CapabilityRules {
41
+ readonly host: HostCapabilities;
42
+ readonly apis: Readonly<Record<string, readonly ApiRequest[]>>;
43
+ }
44
+ /** @internal The SDK's rules: the synced player table and `API_REQUESTS`. */
45
+ export declare const RULES: CapabilityRules;
46
+ /**
47
+ * The player's entry for a method, as the player routes it: the exact name
48
+ * first, then the first `prefix.*` entry whose prefix it starts with.
49
+ * `undefined`: the player does not know the method.
50
+ *
51
+ * @internal
52
+ */
53
+ export declare function hostMethod(method: string, host?: HostCapabilities): HostMethod | undefined;
54
+ /**
55
+ * The capability one request needs, as the player decides it
56
+ * (`capabilities::Route::needs`): `null` for none, `undefined` for a method
57
+ * the player does not know.
58
+ *
59
+ * @param method - The wire method.
60
+ * @param params - Its string parameters that a `Needs::Param` rule reads.
61
+ * @param declared - Whether the plugin declared a capability.
62
+ *
63
+ * @internal
64
+ */
65
+ export declare function requestNeeds(method: string, params: Readonly<Record<string, string>> | undefined, declared: (capability: string) => boolean, host?: HostCapabilities): string | null | undefined;
66
+ /**
67
+ * One capability a member needs: the request that needs it, and the
68
+ * capability whose `requires` brought it in (absent when the request needs
69
+ * it directly).
70
+ *
71
+ * @internal
72
+ */
73
+ export interface MemberNeed {
74
+ readonly capability: string;
75
+ readonly method: string;
76
+ readonly requiredBy?: string;
77
+ }
78
+ /**
79
+ * Every capability a member of the plugin API needs with the given manifest:
80
+ * those its requests need and, through `requires`, those they need in turn.
81
+ * `undefined`: the member is not in the table (it needs none).
82
+ *
83
+ * @param api - The member as `Interface.member` (`Player.seek`).
84
+ * @param capabilities - The manifest's capabilities.
85
+ *
86
+ * @internal
87
+ */
88
+ export declare function memberNeeds(api: string, capabilities: readonly string[], rules?: CapabilityRules): MemberNeed[] | undefined;
89
+ /**
90
+ * `CAPABILITY_INFO` from the tables: the label, highlight and requirements
91
+ * from the player's table; the members from `API_REQUESTS` (every member a
92
+ * request of which needs the capability, in the table's order; requests
93
+ * marked `also` are left out, as they are documented at the member).
94
+ *
95
+ * @internal
96
+ */
97
+ export declare function capabilityInfo(rules?: CapabilityRules): Record<string, {
98
+ apis: string[];
99
+ requires: string[];
100
+ label: string;
101
+ risky: boolean;
102
+ }>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The capability rule, as data: which manifest capability a request needs
3
+ * (the player's table, `HOST_CAPABILITIES`, synced from its
4
+ * `src/host/capabilities.json`) and which requests each public member of the
5
+ * plugin API sends (`API_REQUESTS`). `CAPABILITY_INFO`, `vestra-plugin check`
6
+ * and the TypeScript language-service plugin are all built from these two
7
+ * tables, so they and the player cannot disagree.
8
+ *
9
+ * @internal
10
+ */
11
+ import { API_REQUESTS } from "./api-requests.js";
12
+ import { HOST_CAPABILITIES } from "./host-capabilities.js";
13
+ /** @internal The SDK's rules: the synced player table and `API_REQUESTS`. */
14
+ export const RULES = {
15
+ host: HOST_CAPABILITIES,
16
+ apis: API_REQUESTS,
17
+ };
18
+ /**
19
+ * The player's entry for a method, as the player routes it: the exact name
20
+ * first, then the first `prefix.*` entry whose prefix it starts with.
21
+ * `undefined`: the player does not know the method.
22
+ *
23
+ * @internal
24
+ */
25
+ export function hostMethod(method, host = RULES.host) {
26
+ const all = [
27
+ ...host.capabilities.flatMap((capability) => capability.methods),
28
+ ...host.uncapable,
29
+ ];
30
+ return (all.find((entry) => entry.name === method) ??
31
+ all.find((entry) => entry.name.endsWith(".*") && method.startsWith(entry.name.slice(0, -1))));
32
+ }
33
+ /**
34
+ * The capability one request needs, as the player decides it
35
+ * (`capabilities::Route::needs`): `null` for none, `undefined` for a method
36
+ * the player does not know.
37
+ *
38
+ * @param method - The wire method.
39
+ * @param params - Its string parameters that a `Needs::Param` rule reads.
40
+ * @param declared - Whether the plugin declared a capability.
41
+ *
42
+ * @internal
43
+ */
44
+ export function requestNeeds(method, params, declared, host = RULES.host) {
45
+ const entry = hostMethod(method, host);
46
+ if (!entry)
47
+ return undefined;
48
+ const needs = entry.needs;
49
+ if (needs === null || typeof needs === "string")
50
+ return needs;
51
+ if ("param" in needs) {
52
+ const value = params?.[needs.param];
53
+ return value !== undefined && Object.hasOwn(needs.cases, value)
54
+ ? needs.cases[value]
55
+ : needs.otherwise;
56
+ }
57
+ return declared(needs.ifDeclared) ? needs.then : needs.otherwise;
58
+ }
59
+ /**
60
+ * Every capability a member of the plugin API needs with the given manifest:
61
+ * those its requests need and, through `requires`, those they need in turn.
62
+ * `undefined`: the member is not in the table (it needs none).
63
+ *
64
+ * @param api - The member as `Interface.member` (`Player.seek`).
65
+ * @param capabilities - The manifest's capabilities.
66
+ *
67
+ * @internal
68
+ */
69
+ export function memberNeeds(api, capabilities, rules = RULES) {
70
+ const requests = rules.apis[api];
71
+ if (!requests)
72
+ return undefined;
73
+ // An `ifDeclared` rule is decided last, as if the plugin had declared what
74
+ // the member's other requests need: `Player.seek` needs `playback.control`
75
+ // for `source.submit`, and with it `source.operation` needs nothing more.
76
+ const declared = (name) => capabilities.includes(name) ||
77
+ needs.some((need) => need.capability === name);
78
+ const conditional = (request) => {
79
+ const rule = hostMethod(request.method, rules.host)?.needs;
80
+ return typeof rule === "object" && rule !== null && "ifDeclared" in rule;
81
+ };
82
+ const needs = [];
83
+ const add = (need) => {
84
+ if (needs.some((each) => each.capability === need.capability))
85
+ return;
86
+ needs.push(need);
87
+ const entry = rules.host.capabilities.find((capability) => capability.name === need.capability);
88
+ for (const required of entry?.requires ?? [])
89
+ add({
90
+ capability: required,
91
+ method: need.method,
92
+ requiredBy: need.capability,
93
+ });
94
+ };
95
+ for (const request of [
96
+ ...requests.filter((request) => !conditional(request)),
97
+ ...requests.filter(conditional),
98
+ ]) {
99
+ const capability = requestNeeds(request.method, request.params, declared, rules.host);
100
+ if (capability)
101
+ add({ capability, method: request.method });
102
+ }
103
+ return needs;
104
+ }
105
+ /**
106
+ * `CAPABILITY_INFO` from the tables: the label, highlight and requirements
107
+ * from the player's table; the members from `API_REQUESTS` (every member a
108
+ * request of which needs the capability, in the table's order; requests
109
+ * marked `also` are left out, as they are documented at the member).
110
+ *
111
+ * @internal
112
+ */
113
+ export function capabilityInfo(rules = RULES) {
114
+ const info = {};
115
+ for (const capability of rules.host.capabilities)
116
+ info[capability.name] = {
117
+ apis: [],
118
+ requires: [...capability.requires],
119
+ label: capability.label,
120
+ risky: capability.risky,
121
+ };
122
+ for (const [api, requests] of Object.entries(rules.apis))
123
+ for (const request of requests) {
124
+ if (request.also)
125
+ continue;
126
+ const capability = requestNeeds(request.method, request.params, () => true, rules.host);
127
+ const entry = capability ? info[capability] : undefined;
128
+ if (entry && !entry.apis.includes(api))
129
+ entry.apis.push(api);
130
+ }
131
+ return info;
132
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The player's capability table, copied from its `src/host/capabilities.json`
3
+ * by `npm run capabilities:sync -- <player repository>`. Do not edit: change
4
+ * the player's capability modules, regenerate its JSON and sync again.
5
+ *
6
+ * @internal
7
+ */
8
+ import type { HostCapabilities } from "./capability-rules.ts";
9
+ /** @internal */
10
+ export declare const HOST_CAPABILITIES: HostCapabilities;