From 818ceeff3b416bf99877764aa61b4f74cd600eb1 Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Sun, 3 May 2026 14:07:41 -0300 Subject: [PATCH 1/2] Validate eval/rsg_version against firmware lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds firmware-aware diagnostics for `eval` calls and the manifest's `rsg_version` entry, driven by lifecycle metadata in a new `src/RokuConstants.ts`. New behavior: - `eval(...)` is flagged when the effective rsg_version >= 1.2 (where Roku sunset eval as a compile error). - Manifest `rsg_version` is validated against the configured firmware: - rsg_version=1.0 → flagged as deprecated/removed depending on the user's effective min FW. - rsg_version=1.1 → flagged as removed at firmware 14.5 (Roku silently treats it as 1.2 from there on). - rsg_version=1.2 → flagged as deprecated at firmware 15.1+ in favor of 1.3 (cert deadline 2026-10-01). - rsg_version=1.3 → flagged as requiring firmware 15.0+. - Invalid format (non-semver) → warning. When the user hasn't configured `minFirmwareVersion`, brighterscript now assumes a default of 15.0.0 so these diagnostics are useful out of the box. Users targeting older firmware can opt out by setting `minFirmwareVersion` explicitly. Lifecycle data lives in a single `RSG_VERSIONS` map sourced from Roku's developer release notes and channel-manifest documentation; adding a new rsg_version is one entry there. --- src/DiagnosticMessages.ts | 25 ++ src/Program.spec.ts | 115 ++++++++ src/Program.ts | 86 +++++- src/RokuConstants.ts | 109 ++++++++ .../validation/BrsFileValidator.spec.ts | 131 +++++++++- src/bscPlugin/validation/BrsFileValidator.ts | 45 +++- .../validation/ProgramValidator.spec.ts | 245 ++++++++++++++++++ src/bscPlugin/validation/ProgramValidator.ts | 80 ++++++ .../NullCoalescenceExpression.spec.ts | 4 +- .../expression/TernaryExpression.spec.ts | 4 +- src/preprocessor/Manifest.spec.ts | 61 ++++- src/preprocessor/Manifest.ts | 33 ++- 12 files changed, 916 insertions(+), 22 deletions(-) create mode 100644 src/RokuConstants.ts create mode 100644 src/bscPlugin/validation/ProgramValidator.spec.ts diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 12357a6a3..31aa85ea4 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -770,6 +770,31 @@ export let DiagnosticMessages = { message: `'${featureName}' requires Roku firmware version ${minimumVersion} or higher (current target is ${configuredVersion})`, code: 1146, severity: DiagnosticSeverity.Error + }), + evalIsDeprecatedAtRsgVersion: (rsgVersion: string) => ({ + message: `'eval' is removed in rsg_version=${rsgVersion}`, + code: 1147, + severity: DiagnosticSeverity.Error + }), + rsgVersionRequiresMinFirmware: (rsgVersion: string, requiredFirmware: string, configuredFirmware: string) => ({ + message: `rsg_version=${rsgVersion} requires Roku firmware version ${requiredFirmware} or higher (current target is ${configuredFirmware})`, + code: 1148, + severity: DiagnosticSeverity.Error + }), + invalidRsgVersionFormat: (value: string) => ({ + message: `'${value}' is not a valid rsg_version (expected value like '1.2' or '1.3')`, + code: 1149, + severity: DiagnosticSeverity.Warning + }), + rsgVersionDeprecated: (rsgVersion: string, suggestedReplacement: string) => ({ + message: `rsg_version=${rsgVersion} is deprecated; consider upgrading to rsg_version=${suggestedReplacement}`, + code: 1150, + severity: DiagnosticSeverity.Warning + }), + rsgVersionRemoved: (rsgVersion: string, removedAt: string, replacement: string) => ({ + message: `rsg_version=${rsgVersion} was removed in firmware ${removedAt}; use rsg_version=${replacement}`, + code: 1151, + severity: DiagnosticSeverity.Error }) }; diff --git a/src/Program.spec.ts b/src/Program.spec.ts index f86d6a1ab..85bd9ec03 100644 --- a/src/Program.spec.ts +++ b/src/Program.spec.ts @@ -5,6 +5,7 @@ import * as sinonImport from 'sinon'; import { CancellationTokenSource, CompletionItemKind, Position, Range } from 'vscode-languageserver'; import * as fsExtra from 'fs-extra'; import { DiagnosticMessages } from './DiagnosticMessages'; +import { DEFAULT_MIN_FIRMWARE_VERSION } from './RokuConstants'; import type { BrsFile } from './files/BrsFile'; import type { XmlFile } from './files/XmlFile'; import type { BsConfig } from './BsConfig'; @@ -3814,4 +3815,118 @@ describe('Program', () => { expect(manifest.get('supports_input_launch')).to.equal('1'); } }); + + describe('getEffectiveMinFirmwareVersion', () => { + it(`returns DEFAULT_MIN_FIRMWARE_VERSION when minFirmwareVersion is unset`, () => { + program.dispose(); + program = new Program({}); + expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + }); + + it(`returns the user's value when minFirmwareVersion is set`, () => { + program.dispose(); + program = new Program({ minFirmwareVersion: '11.5.0' }); + expect(program.getEffectiveMinFirmwareVersion()).to.equal('11.5.0'); + }); + + it('returns DEFAULT when minFirmwareVersion is set to garbage', () => { + program.dispose(); + program = new Program({ minFirmwareVersion: 'banana' }); + expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + }); + + it('returns DEFAULT when minFirmwareVersion is an empty string', () => { + program.dispose(); + program = new Program({ minFirmwareVersion: '' }); + expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + }); + + it(`returns the user's value when it parses as semver`, () => { + program.dispose(); + program = new Program({ minFirmwareVersion: '11.5' }); + expect(program.getEffectiveMinFirmwareVersion()).to.equal('11.5'); + }); + }); + + describe('getRsgVersion', () => { + function setupWith(manifestContents: string | undefined, minFirmwareVersion?: string) { + if (manifestContents !== undefined) { + fsExtra.writeFileSync(`${tempDir}/manifest`, manifestContents); + } + program.dispose(); + program = new Program({ + rootDir: tempDir, + minFirmwareVersion: minFirmwareVersion + }); + } + + it(`returns the manifest's explicit value when set`, () => { + setupWith(trim` + title=t + rsg_version=1.1 + `); + expect(program.getRsgVersion()).to.equal('1.1'); + }); + + it(`returns '1.2' when manifest is silent and effective firmware >= 9.3.0 (the default)`, () => { + setupWith(trim`title=t`); + expect(program.getRsgVersion()).to.equal('1.2'); + }); + + it(`returns '1.1' when manifest is silent and minFirmwareVersion is between 7.5.0 and 9.3.0`, () => { + setupWith(trim`title=t`, '8.0.0'); + expect(program.getRsgVersion()).to.equal('1.1'); + }); + + it(`returns '1.0' when manifest is silent and minFirmwareVersion is below 7.5.0 (pre-1.1 firmware)`, () => { + setupWith(trim`title=t`, '7.0.0'); + expect(program.getRsgVersion()).to.equal('1.0'); + }); + + it(`returns '1.3' when manifest is silent and minFirmwareVersion is >= 15.1.0`, () => { + //1.3.becameDefaultAt is 15.1.0 (Roku's cert-policy "expected default" for new development at this firmware) + setupWith(trim`title=t`, '15.1.0'); + expect(program.getRsgVersion()).to.equal('1.3'); + }); + + it(`is data-driven: a forward-compat unknown rsg_version is returned verbatim from the manifest`, () => { + //we don't know about 1.5 in RSG_VERSIONS, but the manifest's explicit value still wins + setupWith(trim` + title=t + rsg_version=1.5 + `); + expect(program.getRsgVersion()).to.equal('1.5'); + }); + + it(`falls back to the default firmware (and its rsg_version) when minFirmwareVersion is unparseable garbage`, () => { + //getEffectiveMinFirmwareVersion sanitizes garbage input → DEFAULT (15.0.0) + //→ getRsgVersion picks the highest matching default → '1.2' + setupWith(trim`title=t`, 'not-a-version'); + expect(program.getRsgVersion()).to.equal('1.2'); + }); + }); + + describe('getManifestEntries', () => { + it('returns line-aware entries for each manifest line', () => { + fsExtra.writeFileSync(`${tempDir}/manifest`, trim` + title=t + rsg_version=1.2 + `); + program.dispose(); + program = new Program({ rootDir: tempDir }); + //getManifestEntries is protected; use bracket access for tests + // eslint-disable-next-line @typescript-eslint/dot-notation + const entries = program['getManifestEntries'](); + expect(entries).to.have.lengthOf(2); + expect(entries[1]).to.deep.include({ key: 'rsg_version', value: '1.2' }); + expect(entries[1].range.start.line).to.equal(1); + }); + + it('returns an empty array when no manifest exists', () => { + program.dispose(); + program = new Program({ rootDir: tempDir }); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(program['getManifestEntries']()).to.eql([]); + }); + }); }); diff --git a/src/Program.ts b/src/Program.ts index ef4c29b2b..6df5a1d92 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -1,6 +1,7 @@ import * as assert from 'assert'; import * as fsExtra from 'fs-extra'; import * as path from 'path'; +import * as semver from 'semver'; import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange } from 'vscode-languageserver'; import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver'; import type { BsConfig, FinalizedBsConfig } from './BsConfig'; @@ -21,7 +22,9 @@ import type { Logger } from './logging'; import { LogLevel, createLogger } from './logging'; import chalk from 'chalk'; import { globalFile } from './globalCallables'; -import { parseManifest, getBsConst } from './preprocessor/Manifest'; +import { parseManifest, parseManifestEntries, getBsConst } from './preprocessor/Manifest'; +import type { ManifestEntry } from './preprocessor/Manifest'; +import { DEFAULT_MIN_FIRMWARE_VERSION, RSG_VERSIONS } from './RokuConstants'; import { URI } from 'vscode-uri'; import PluginInterface from './PluginInterface'; import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection'; @@ -1718,6 +1721,7 @@ export class Program { } private _manifest: Map; + private _manifestEntries: ManifestEntry[]; /** * Modify a parsed manifest map by reading `bs_const` and injecting values from `options.manifest.bs_const` @@ -1767,8 +1771,12 @@ export class Program { const parsedManifest = parseManifest(contents); this.buildBsConstsIntoParsedManifest(parsedManifest); this._manifest = parsedManifest; + this._manifestEntries = parseManifestEntries(contents); + this._manifestPath = manifestPath; } catch (e) { this._manifest = new Map(); + this._manifestEntries = []; + this._manifestPath = undefined; } } @@ -1782,6 +1790,82 @@ export class Program { return this._manifest; } + /** + * Get the manifest as a list of `{ key, value, range }` entries with line/column ranges + * suitable for attaching diagnostics to specific manifest lines. + * + * NOTE: protected for now. The shape of this data is likely to evolve as we build out + * more manifest-aware features (validation rules, autocomplete, etc.). External plugins + * shouldn't depend on this until we commit to a stable API. + */ + protected getManifestEntries(): ManifestEntry[] { + if (!this._manifestEntries) { + this.loadManifest(); + } + return this._manifestEntries; + } + + private _manifestPath: string | undefined; + + /** + * Returns the absolute path of the loaded manifest file, or undefined if no manifest was found. + * + * NOTE: protected for now. Once brighterscript treats the manifest as a proper file + * (with editor / BscFile integration) callers should consume that instead of poking at + * the Program. External plugins shouldn't depend on this in the meantime. + */ + protected getManifestPath(): string | undefined { + if (!this._manifest) { + this.loadManifest(); + } + return this._manifestPath; + } + + /** + * The minimum Roku firmware version brighterscript should assume the user is targeting. + * If `options.minFirmwareVersion` is set AND parseable as semver, that wins; otherwise + * (unset or unparseable) falls back to {@link DEFAULT_MIN_FIRMWARE_VERSION}. This + * guarantees the return is always a coerceable version string, so downstream callers + * never have to handle malformed input. + */ + public getEffectiveMinFirmwareVersion(): string { + const userValue = this.options.minFirmwareVersion; + if (userValue && semver.coerce(userValue)) { + return userValue; + } + return DEFAULT_MIN_FIRMWARE_VERSION; + } + + /** + * Returns the effective `rsg_version` for this program. If the manifest declares a value + * explicitly, that's returned verbatim (including malformed values, so callers can validate + * format themselves). Otherwise, the highest known rsg_version whose `becameDefaultAt` is + * `<=` the effective minimum firmware version is returned — driven entirely by the data in + * {@link RSG_VERSIONS}. + */ + public getRsgVersion(): string { + const explicit = this.getManifest().get('rsg_version'); + if (explicit !== undefined) { + return explicit.trim(); + } + //getEffectiveMinFirmwareVersion guarantees a coerceable return, so this never throws + const coercedFw = semver.coerce(this.getEffectiveMinFirmwareVersion())!; + //walk known rsg_versions in descending order (newest first) and return the first whose + //becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0' + //(currently `1.1`), this loop always finds a match. + const candidates = Object.entries(RSG_VERSIONS) + .filter(([, info]) => info.becameDefaultAt !== undefined) + .sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!)); + for (const [version, info] of candidates) { + const coercedDefault = semver.coerce(info.becameDefaultAt!); + if (coercedDefault && semver.gte(coercedFw, coercedDefault)) { + return version; + } + } + //unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0' + return DEFAULT_MIN_FIRMWARE_VERSION; + } + public dispose() { this.plugins.emit('beforeProgramDispose', { program: this }); diff --git a/src/RokuConstants.ts b/src/RokuConstants.ts new file mode 100644 index 000000000..6f81c4d16 --- /dev/null +++ b/src/RokuConstants.ts @@ -0,0 +1,109 @@ +/** + * Lifecycle metadata for a single `rsg_version` value. + * + * Sources: Roku's developer release notes and channel-manifest documentation. When adding a new + * rsg_version, record the firmware versions at each lifecycle transition rather than scattering + * version numbers across the codebase. + */ +export interface RsgVersionInfo { + /** + * Minimum Roku firmware version that can compile/run this rsg_version. If `effectiveFw < + * introducedAt` and the manifest declares this version, the project is misconfigured. + */ + introducedAt: string; + + /** + * Firmware version where this rsg_version becomes the expected default for new development + * at that firmware target. For older versions (1.0/1.1/1.2) this corresponds to Roku's + * device-side silent fallback (what runs when the manifest is silent). For 1.3 it + * corresponds to Roku's static-analysis cert requirement (the firmware at which the cert + * tool flags channels for not declaring 1.3). `getRsgVersion()` walks this field + * (highest matching wins) to figure out what version a project should target. + */ + becameDefaultAt?: string; + + /** + * Firmware version that deprecated this rsg_version. Channels using a deprecated version + * still function with the semantics they declared but are flagged for upgrade. + */ + deprecatedAt?: string; + + /** + * Firmware version where this rsg_version stopped being honored as declared. From this + * firmware on, the device either refuses to run the channel or silently substitutes a + * different rsg_version (`replacement`). Either way, the manifest entry no longer means + * what the developer wrote, so this is treated as an error-severity diagnostic. + */ + removedAt?: string; + + /** + * Suggested replacement value. Used both for the deprecation suggestion and (when set + * together with `removedAt`) to describe what the device silently runs instead. + */ + replacement?: string; +} + +/** + * Single source of truth for known `rsg_version` values. Adding a new RSG version means adding + * one entry here; downstream validators and `Program.getRsgVersion()` will pick it up. + * + * Versions not present in this map are treated as "unknown but valid" (assuming they parse as + * semver). brighterscript can't validate firmware compatibility for versions it hasn't been + * updated to recognize. + */ +export const RSG_VERSIONS: Record = { + '1.0': { + introducedAt: '0.0.0', // launched with Roku SceneGraph itself (Oct 2015); pre-dates the 1.1 flag + becameDefaultAt: '0.0.0', // was the default until Roku OS 7.5 introduced 1.1 as the new default + deprecatedAt: '8.0.0', // Roku OS 8 deprecated rsg_version=1.0 + removedAt: '9.0.0', // Roku OS 9 dropped support for rsg_version=1.0 entirely + replacement: '1.2' + }, + '1.1': { + introducedAt: '7.5.0', // Roku OS 7.5 introduced rsg_version=1.1 + becameDefaultAt: '7.5.0', // also became the default in 7.5, replacing 1.0 as the silent default + removedAt: '14.5.0', // Roku OS 14.5 silently treats rsg_version=1.1 as 1.2 — manifest entry no longer honored + replacement: '1.2' + }, + '1.2': { + introducedAt: '9.0.0', // Roku OS 9.0 introduced rsg_version=1.2 as opt-in + becameDefaultAt: '9.3.0', // Roku OS 9.3 made rsg_version=1.2 the manifest-silent default + + //Roku announced 1.2 as "being deprecated" alongside the OS 15.0 launch (Oct 2025), with a + //2026-10-01 certification deadline. We gate the warning on 15.1.0 (the firmware where 1.3 + //became the required minimum for new app submissions) — a project targeting that firmware + //or newer can actually adopt 1.3, so the warning is actionable. + deprecatedAt: '15.1.0', + replacement: '1.3' + }, + '1.3': { + introducedAt: '15.0.0', // Roku OS 15.0 made rsg_version=1.3 available (Oct 2025) + becameDefaultAt: '15.1.0' // Roku's static analysis cert tool requires rsg_version=1.3 + //when the manifest's minFirmwareVersion is 15.1.0+ (warning today, blocks publishing + //starting Oct 1, 2026). This is the firmware where 1.3 effectively becomes the + //expected default for new development. TODO: confirm exact semantics with Roku. + } +}; + +/** + * Default minimum Roku firmware version assumed when the user hasn't configured + * `minFirmwareVersion`. Chosen to reflect a modern Roku target so that diagnostics relevant to + * current firmware fire by default. Users targeting older firmware should set + * `minFirmwareVersion` explicitly. + */ +export const DEFAULT_MIN_FIRMWARE_VERSION = '15.0.0'; + +/** + * Minimum Roku firmware version that introduced optional chaining (`?.`, `?[`, `?(`). + * Optional chaining is NOT transpiled by BrighterScript, so this restriction applies to both + * .brs and .bs files — the target device must natively support it. + * Source: Roku OS 11 release notes. + */ +export const OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION = '11.0.0'; + +/** + * The rsg_version at which `eval()` becomes a compile error on device. + * Source: Roku OS 9.0 release notes — `eval()` deprecated when rsg_version=1.2 is set; + * sunset entirely in Roku OS 9.3. + */ +export const EVAL_REMOVED_AT_RSG_VERSION = '1.2.0'; diff --git a/src/bscPlugin/validation/BrsFileValidator.spec.ts b/src/bscPlugin/validation/BrsFileValidator.spec.ts index ceadd3adb..b9bd0b72e 100644 --- a/src/bscPlugin/validation/BrsFileValidator.spec.ts +++ b/src/bscPlugin/validation/BrsFileValidator.spec.ts @@ -3,10 +3,11 @@ import type { BrsFile } from '../../files/BrsFile'; import type { AALiteralExpression, DottedGetExpression } from '../../parser/Expression'; import type { ClassStatement, FunctionStatement, NamespaceStatement, PrintStatement } from '../../parser/Statement'; import { DiagnosticMessages } from '../../DiagnosticMessages'; -import { expectDiagnostics, expectZeroDiagnostics } from '../../testHelpers.spec'; +import { expectDiagnostics, expectZeroDiagnostics, tempDir, trim } from '../../testHelpers.spec'; import { Program } from '../../Program'; import { isClassStatement, isNamespaceStatement } from '../../astUtils/reflection'; import util from '../../util'; +import * as fsExtra from 'fs-extra'; describe('BrsFileValidator', () => { let program: Program; @@ -608,4 +609,132 @@ describe('BrsFileValidator', () => { }); }); }); + + describe('eval deprecation', () => { + beforeEach(() => { + fsExtra.ensureDirSync(tempDir); + fsExtra.emptyDirSync(tempDir); + }); + afterEach(() => { + fsExtra.emptyDirSync(tempDir); + }); + + function setupProgram(opts: { rsgVersion?: string; minFirmwareVersion?: string }) { + const manifestContents = opts.rsgVersion + ? trim` + title=t + rsg_version=${opts.rsgVersion} + ` + : trim`title=t`; + fsExtra.writeFileSync(`${tempDir}/manifest`, manifestContents); + program.dispose(); + program = new Program({ + rootDir: tempDir, + minFirmwareVersion: opts.minFirmwareVersion + }); + } + + it('flags `eval(...)` under default settings (no manifest rsg_version, default minFirmwareVersion)', () => { + //default minFirmwareVersion is 15.0.0, so effective rsg_version is 1.2 + setupProgram({}); + program.setFile('source/main.brs', ` + sub main() + eval("print 1") + end sub + `); + program.validate(); + expectDiagnostics(program, [{ + ...DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2') + }]); + }); + + it('flags `eval(...)` when manifest declares rsg_version=1.2', () => { + setupProgram({ rsgVersion: '1.2' }); + program.setFile('source/main.brs', ` + sub main() + eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + ); + expect(evalDiags).to.be.lengthOf(1); + }); + + it('flags `eval(...)` when manifest declares rsg_version=1.3', () => { + setupProgram({ rsgVersion: '1.3' }); + program.setFile('source/main.brs', ` + sub main() + eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.3').code + ); + expect(evalDiags).to.be.lengthOf(1); + }); + + it('does NOT flag `eval(...)` when manifest declares rsg_version=1.1', () => { + setupProgram({ rsgVersion: '1.1' }); + program.setFile('source/main.brs', ` + sub main() + eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.1').code + ); + expect(evalDiags).to.be.lengthOf(0); + }); + + it('does NOT flag `eval(...)` when minFirmwareVersion is set below 9.3.0 and manifest is silent', () => { + setupProgram({ minFirmwareVersion: '8.0.0' }); + program.setFile('source/main.brs', ` + sub main() + eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.1').code + ); + expect(evalDiags).to.be.lengthOf(0); + }); + + it('does NOT flag `m.eval(...)` (method call on object)', () => { + setupProgram({}); + program.setFile('source/main.brs', ` + sub main() + m.eval("print 1") + end sub + `); + program.validate(); + expectZeroDiagnostics(program); + }); + + it('does NOT flag `alpha.eval(...)` (namespaced call via dotted-get)', () => { + setupProgram({}); + program.setFile('source/main.brs', ` + sub main() + alpha.eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + ); + expect(evalDiags).to.be.lengthOf(0); + }); + + it('flags eval case-insensitively (Eval, EVAL)', () => { + setupProgram({}); + program.setFile('source/main.brs', ` + sub main() + Eval("print 1") + end sub + `); + program.validate(); + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + ); + expect(evalDiags).to.be.lengthOf(1); + }); + }); }); diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts index 9d99ae8fc..d14cd8c97 100644 --- a/src/bscPlugin/validation/BrsFileValidator.ts +++ b/src/bscPlugin/validation/BrsFileValidator.ts @@ -1,4 +1,4 @@ -import { isAliasStatement, isBody, isClassStatement, isCommentStatement, isConstStatement, isDottedGetExpression, isDottedSetStatement, isEnumStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isImportStatement, isIndexedGetExpression, isIndexedSetStatement, isInterfaceStatement, isLibraryStatement, isLiteralExpression, isNamespaceStatement, isTypecastStatement, isTypeStatement, isUnaryExpression, isWhileStatement } from '../../astUtils/reflection'; +import { isAliasStatement, isBody, isClassStatement, isCommentStatement, isConstStatement, isDottedGetExpression, isDottedSetStatement, isEnumStatement, isForEachStatement, isForStatement, isFunctionExpression, isFunctionStatement, isImportStatement, isIndexedGetExpression, isIndexedSetStatement, isInterfaceStatement, isLibraryStatement, isLiteralExpression, isNamespaceStatement, isTypecastStatement, isTypeStatement, isUnaryExpression, isVariableExpression, isWhileStatement } from '../../astUtils/reflection'; import { createVisitor, WalkMode } from '../../astUtils/visitors'; import { DiagnosticMessages } from '../../DiagnosticMessages'; import type { BrsFile } from '../../files/BrsFile'; @@ -13,6 +13,7 @@ import { InterfaceType } from '../../types/InterfaceType'; import util from '../../util'; import type { Range } from 'vscode-languageserver'; import * as semver from 'semver'; +import { EVAL_REMOVED_AT_RSG_VERSION, OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION } from '../../RokuConstants'; export class BrsFileValidator { constructor( @@ -62,6 +63,7 @@ export class BrsFileValidator { if (node.openingParen?.kind === TokenKind.QuestionLeftParen) { this.validateMinFirmwareVersionForOptionalChaining(node.openingParen.range); } + this.validateEvalIsNotDeprecated(node); }, EnumStatement: (node) => { this.validateDeclarationLocations(node, 'enum', () => util.createBoundingRange(node.tokens.enum, node.tokens.name)); @@ -465,13 +467,6 @@ export class BrsFileValidator { } } - /** - * The minimum Roku firmware version that introduced optional chaining support (Roku OS 11). - * Optional chaining is NOT transpiled by BrighterScript, so this restriction applies to both - * .brs and .bs files. - */ - private static readonly OPTIONAL_CHAINING_MIN_VERSION = '11.0.0'; - /** * Add a diagnostic if the configured minFirmwareVersion is lower than the version that * introduced optional chaining support (Roku OS 11). @@ -479,20 +474,42 @@ export class BrsFileValidator { * it is emitted as-is, so the target device must natively support it. */ private validateMinFirmwareVersionForOptionalChaining(range: Range | undefined) { - const minFirmwareVersion = this.event.file.program.options.minFirmwareVersion; - if (!minFirmwareVersion) { - return; - } + const minFirmwareVersion = this.event.file.program.getEffectiveMinFirmwareVersion(); const coercedMinVersion = semver.coerce(minFirmwareVersion); - if (coercedMinVersion && semver.lt(coercedMinVersion, BrsFileValidator.OPTIONAL_CHAINING_MIN_VERSION)) { + if (coercedMinVersion && semver.lt(coercedMinVersion, OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION)) { this.event.file.addDiagnostic({ ...DiagnosticMessages.featureRequiresMinFirmwareVersion( 'optional chaining', - BrsFileValidator.OPTIONAL_CHAINING_MIN_VERSION, + OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION, minFirmwareVersion ), range: range }); } } + + /** + * Add a diagnostic if a CallExpression invokes the bare `eval` builtin under an effective + * rsg_version of 1.2 or higher. On device, this is a compile error (eval was deprecated in + * Roku OS 9.0 with rsg_version=1.2 and removed in 9.3). + * Skips method calls (`m.eval(x)`) and namespaced calls (`alpha.eval(x)`) — only flags the + * bare top-level builtin. + */ + private validateEvalIsNotDeprecated(node: CallExpression) { + if (!isVariableExpression(node.callee)) { + return; + } + if (node.callee.name?.text?.toLowerCase() !== 'eval') { + return; + } + const rsgVersion = this.event.file.program.getRsgVersion(); + const coercedRsgVersion = semver.coerce(rsgVersion); + if (!coercedRsgVersion || semver.lt(coercedRsgVersion, EVAL_REMOVED_AT_RSG_VERSION)) { + return; + } + this.event.file.addDiagnostic({ + ...DiagnosticMessages.evalIsDeprecatedAtRsgVersion(rsgVersion), + range: node.callee.range + }); + } } diff --git a/src/bscPlugin/validation/ProgramValidator.spec.ts b/src/bscPlugin/validation/ProgramValidator.spec.ts new file mode 100644 index 000000000..f57c9081e --- /dev/null +++ b/src/bscPlugin/validation/ProgramValidator.spec.ts @@ -0,0 +1,245 @@ +import { expect } from '../../chai-config.spec'; +import { Program } from '../../Program'; +import { DiagnosticMessages } from '../../DiagnosticMessages'; +import { tempDir, trim } from '../../testHelpers.spec'; +import * as fsExtra from 'fs-extra'; + +describe('ProgramValidator', () => { + describe('manifest rsg_version validation', () => { + let program: Program; + + beforeEach(() => { + fsExtra.ensureDirSync(tempDir); + fsExtra.emptyDirSync(tempDir); + }); + afterEach(() => { + program?.dispose(); + fsExtra.emptyDirSync(tempDir); + }); + + function setup(manifestContent: string, opts: { minFirmwareVersion?: string } = {}) { + fsExtra.writeFileSync(`${tempDir}/manifest`, manifestContent); + program = new Program({ + rootDir: tempDir, + minFirmwareVersion: opts.minFirmwareVersion + }); + } + + function getCodes() { + return program.getDiagnostics().map(d => d.code); + } + + it('flags rsg_version=1.0 as REMOVED under default firmware (15.0 >= 9.0)', () => { + //1.0 was both deprecated (8.0) and removed (9.0); removal takes precedence + setup(trim` + title=t + rsg_version=1.0 + `); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionRemoved('1.0', '9.0.0', '1.2').code + ); + //the deprecation diagnostic should NOT also fire (removal takes precedence) + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionDeprecated('1.0', '1.2').code + ); + }); + + it('flags rsg_version=1.0 as deprecated when firmware is in the deprecation window (8.0 <= fw < 9.0)', () => { + setup(trim` + title=t + rsg_version=1.0 + `, { minFirmwareVersion: '8.5.0' }); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionDeprecated('1.0', '1.2').code + ); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionRemoved('1.0', '9.0.0', '1.2').code + ); + }); + + it('does NOT flag rsg_version=1.0 as deprecated when minFirmwareVersion is below 8.0.0', () => { + //pre-deprecation firmware: 1.0 was still supported, no warning needed + setup(trim` + title=t + rsg_version=1.0 + `, { minFirmwareVersion: '7.0.0' }); + program.validate(); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionDeprecated('1.0', '1.2').code + ); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionRemoved('1.0', '9.0.0', '1.2').code + ); + }); + + it('flags rsg_version=1.1 as REMOVED under default firmware (15.0 >= 14.5)', () => { + //OS 14.5 silently substitutes rsg_version=1.1 → 1.2; manifest entry no longer honored + setup(trim` + title=t + rsg_version=1.1 + `); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionRemoved('1.1', '14.5.0', '1.2').code + ); + }); + + it('does NOT flag rsg_version=1.1 as removed when minFirmwareVersion is below 14.5.0', () => { + setup(trim` + title=t + rsg_version=1.1 + `, { minFirmwareVersion: '11.0.0' }); + program.validate(); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionRemoved('1.1', '14.5.0', '1.2').code + ); + }); + + it('flags rsg_version=1.1 as requiring firmware 7.5.0 when targeting older firmware', () => { + //pre-7.5 firmware didn't know about rsg_version=1.1 - the manifest entry would be invalid there + setup(trim` + title=t + rsg_version=1.1 + `, { minFirmwareVersion: '7.0.0' }); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionRequiresMinFirmware('1.1', '7.5.0', '7.0.0').code + ); + }); + + it('flags rsg_version=1.3 with firmware below 15.0.0 as requiring 15.0.0', () => { + //capability check: 1.3 was introduced at OS 15.0 + setup(trim` + title=t + rsg_version=1.3 + `, { minFirmwareVersion: '14.0.0' }); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionRequiresMinFirmware('1.3', '15.0.0', '14.0.0').code + ); + }); + + it('does NOT flag rsg_version=1.3 capability check when minFirmwareVersion is 15.0.0+', () => { + //1.3 works at 15.0 (capability). Roku's cert policy requires 15.1.0+ for new + //submissions but that's a separate concern not yet modeled (TODO in RokuConstants). + setup(trim` + title=t + rsg_version=1.3 + `, { minFirmwareVersion: '15.0.0' }); + program.validate(); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionRequiresMinFirmware('1.3', '15.0.0', '15.0.0').code + ); + }); + + it('flags rsg_version=1.2 when minFirmwareVersion is below 9.0.0', () => { + setup(trim` + title=t + rsg_version=1.2 + `, { minFirmwareVersion: '8.0.0' }); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionRequiresMinFirmware('1.2', '9.0.0', '8.0.0').code + ); + }); + + it('does NOT flag rsg_version=1.2 with default firmware (15.0.0)', () => { + //15.0 < 15.1 (1.2's deprecation threshold), so no deprecation warning either + setup(trim` + title=t + rsg_version=1.2 + `); + program.validate(); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionRequiresMinFirmware('1.2', '9.0.0', '15.0.0').code + ); + expect(getCodes()).to.not.include( + DiagnosticMessages.rsgVersionDeprecated('1.2', '1.3').code + ); + }); + + it('flags rsg_version=1.2 as deprecated when minFirmwareVersion is >= 15.1.0', () => { + //projects targeting 15.1+ can adopt 1.3, so the deprecation warning is actionable + setup(trim` + title=t + rsg_version=1.2 + `, { minFirmwareVersion: '15.1.0' }); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.rsgVersionDeprecated('1.2', '1.3').code + ); + }); + + it('flags an invalid rsg_version format', () => { + setup(trim` + title=t + rsg_version=banana + `); + program.validate(); + expect(getCodes()).to.include( + DiagnosticMessages.invalidRsgVersionFormat('banana').code + ); + }); + + it('does NOT flag a forward-compatible-but-unknown rsg_version (1.5)', () => { + //we don't know about 1.5, but it parses as semver - trust it. + //no min-FW or deprecation diagnostic should fire. + setup(trim` + title=t + rsg_version=1.5 + `); + program.validate(); + const codes = getCodes(); + expect(codes).to.not.include( + DiagnosticMessages.invalidRsgVersionFormat('1.5').code + ); + expect(codes.some(c => c === DiagnosticMessages.rsgVersionRequiresMinFirmware('1.5', '', '').code + )).to.equal(false); + }); + + it('does NOT flag a manifest with no rsg_version entry', () => { + setup(trim`title=t`); + program.validate(); + const codes = getCodes(); + expect(codes).to.not.include( + DiagnosticMessages.invalidRsgVersionFormat('').code + ); + expect(codes).to.not.include( + DiagnosticMessages.rsgVersionDeprecated('', '').code + ); + }); + + it('does NOT fire any rsg_version diagnostic when no manifest exists', () => { + //skip writing the manifest file + program = new Program({ rootDir: tempDir }); + program.validate(); + const rsgCodes = [ + DiagnosticMessages.invalidRsgVersionFormat('').code, + DiagnosticMessages.rsgVersionDeprecated('', '').code, + DiagnosticMessages.rsgVersionRequiresMinFirmware('', '', '').code + ]; + const codes = getCodes(); + for (const c of rsgCodes) { + expect(codes).to.not.include(c); + } + }); + + it('attaches the diagnostic range to the rsg_version line', () => { + //pick a firmware that puts rsg_version=1.0 in its deprecation window (8.0 <= fw < 9.0) + //so the deprecation diagnostic fires (not removal); verify the range maps to the value text. + setup(trim` + title=t + rsg_version=1.0 + `, { minFirmwareVersion: '8.5.0' }); + program.validate(); + const diag = program.getDiagnostics().find(d => d.code === DiagnosticMessages.rsgVersionDeprecated('1.0', '1.2').code + ); + expect(diag).to.exist; + //rsg_version is on line 1 (0-indexed); the value `1.0` starts at character 12 (after `rsg_version=`) + expect(diag!.range.start.line).to.equal(1); + expect(diag!.range.start.character).to.equal(12); + }); + }); +}); diff --git a/src/bscPlugin/validation/ProgramValidator.ts b/src/bscPlugin/validation/ProgramValidator.ts index cf56d9766..7a30a15e9 100644 --- a/src/bscPlugin/validation/ProgramValidator.ts +++ b/src/bscPlugin/validation/ProgramValidator.ts @@ -1,13 +1,16 @@ import { isBrsFile } from '../../astUtils/reflection'; import { DiagnosticMessages } from '../../DiagnosticMessages'; import type { Program } from '../../Program'; +import { RSG_VERSIONS } from '../../RokuConstants'; import util from '../../util'; +import * as semver from 'semver'; export class ProgramValidator { constructor(private program: Program) { } public process() { this.flagScopelessBrsFiles(); + this.validateManifest(); } /** @@ -33,4 +36,81 @@ export class ProgramValidator { }]); } } + + /** + * Validate the manifest's `rsg_version` entry. Lifecycle data is sourced from the + * `RSG_VERSIONS` map in `src/RokuConstants.ts`; this validator simply derives diagnostics + * from those fields: + * - format must be parseable as semver + * - cross-check `introducedAt` against effective `minFirmwareVersion` + * - flag versions with a `deprecatedAt` lifecycle field (e.g. 1.0) + * Versions not in the map are treated as "unknown but valid" — no diagnostic. + */ + private validateManifest() { + //getManifestEntries and getManifestPath are intentionally protected for now (no stable + //public API yet) — use bracket access here to bypass the visibility check. + /* eslint-disable @typescript-eslint/dot-notation */ + const entries = this.program['getManifestEntries'](); + const manifestPath = this.program['getManifestPath'](); + /* eslint-enable @typescript-eslint/dot-notation */ + if (!entries || entries.length === 0 || !manifestPath) { + return; + } + const rsgEntry = entries.find(e => e.key.trim() === 'rsg_version'); + if (!rsgEntry) { + return; + } + const value = rsgEntry.value.trim(); + const coercedRsg = semver.coerce(value); + if (!coercedRsg) { + this.program.addDiagnostics([{ + ...DiagnosticMessages.invalidRsgVersionFormat(value), + file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, + range: rsgEntry.range + }]); + return; + } + + const info = RSG_VERSIONS[value]; + if (!info) { + //version is parseable as semver but not in our known map — trust the user; + //we don't have firmware-compat data for it. + return; + } + + const effectiveFw = this.program.getEffectiveMinFirmwareVersion(); + const coercedFw = semver.coerce(effectiveFw); + + //removal takes precedence over deprecation. If `removedAt <= effectiveFw`, fire the + //removal error and skip the deprecation warning — the manifest entry is no longer honored. + const coercedRemoved = info.removedAt ? semver.coerce(info.removedAt) : undefined; + const removedFiresHere = coercedFw && coercedRemoved && semver.gte(coercedFw, coercedRemoved); + if (removedFiresHere && info.replacement) { + this.program.addDiagnostics([{ + ...DiagnosticMessages.rsgVersionRemoved(value, info.removedAt!, info.replacement), + file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, + range: rsgEntry.range + }]); + } else if (info.deprecatedAt && info.replacement) { + //fire deprecation only when the effective firmware is >= the deprecation point — projects + //targeting pre-deprecation firmware can legitimately keep using the old version. + const coercedDeprecated = semver.coerce(info.deprecatedAt); + if (coercedFw && coercedDeprecated && semver.gte(coercedFw, coercedDeprecated)) { + this.program.addDiagnostics([{ + ...DiagnosticMessages.rsgVersionDeprecated(value, info.replacement), + file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, + range: rsgEntry.range + }]); + } + } + + const coercedRequired = semver.coerce(info.introducedAt); + if (coercedFw && coercedRequired && semver.lt(coercedFw, coercedRequired)) { + this.program.addDiagnostics([{ + ...DiagnosticMessages.rsgVersionRequiresMinFirmware(value, info.introducedAt, effectiveFw), + file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, + range: rsgEntry.range + }]); + } + } } diff --git a/src/parser/tests/expression/NullCoalescenceExpression.spec.ts b/src/parser/tests/expression/NullCoalescenceExpression.spec.ts index a9dfccc1a..971d399b3 100644 --- a/src/parser/tests/expression/NullCoalescenceExpression.spec.ts +++ b/src/parser/tests/expression/NullCoalescenceExpression.spec.ts @@ -259,6 +259,8 @@ describe('NullCoalescingExpression', () => { }); it('does not capture restricted OS functions', () => { + //failOnDiagnostic=false: the bare `eval("print 1")` triggers the rsg_version=1.2 + //deprecation diagnostic under default settings, which is unrelated to what this test covers. testTranspile(` sub main() num = 1 @@ -303,7 +305,7 @@ describe('NullCoalescingExpression', () => { sub test(p1) end sub - `); + `, 'trim', 'source/main.bs', false); }); it('properly transpiles null coalesence assignments - complex alternate', () => { diff --git a/src/parser/tests/expression/TernaryExpression.spec.ts b/src/parser/tests/expression/TernaryExpression.spec.ts index 74fa61e7c..bbc339c96 100644 --- a/src/parser/tests/expression/TernaryExpression.spec.ts +++ b/src/parser/tests/expression/TernaryExpression.spec.ts @@ -557,6 +557,8 @@ describe('ternary expressions', () => { }); it('does not capture restricted OS functions', () => { + //failOnDiagnostic=false: the bare `eval("print 1")` triggers the rsg_version=1.2 + //deprecation diagnostic under default settings, which is unrelated to what this test covers. testTranspile(` sub main() test(true ? invalid : [ @@ -598,7 +600,7 @@ describe('ternary expressions', () => { sub test(p1) end sub - `); + `, 'trim', 'source/main.bs', false); }); it('complex conditions do not cause scope capture', () => { diff --git a/src/preprocessor/Manifest.spec.ts b/src/preprocessor/Manifest.spec.ts index cf9e472e6..da67bcfa0 100644 --- a/src/preprocessor/Manifest.spec.ts +++ b/src/preprocessor/Manifest.spec.ts @@ -1,6 +1,6 @@ import * as fsExtra from 'fs'; import { expect } from '../chai-config.spec'; -import { getManifest, getBsConst, parseManifest } from './Manifest'; +import { getManifest, getBsConst, parseManifest, parseManifestEntries } from './Manifest'; import { createSandbox } from 'sinon'; import { expectThrows, mapToObject, objectToMap, trim } from '../testHelpers.spec'; const sinon = createSandbox(); @@ -63,6 +63,65 @@ describe('manifest support', () => { }); }); + describe('parseManifestEntries (line-aware)', () => { + it('returns entries with key, value, and a range that covers just the value', () => { + const entries = parseManifestEntries(trim` + title=t + rsg_version=1.2 + `); + expect(entries).to.have.lengthOf(2); + expect(entries[0]).to.deep.include({ key: 'title', value: 't' }); + expect(entries[0].range.start).to.eql({ line: 0, character: 6 }); + expect(entries[0].range.end).to.eql({ line: 0, character: 7 }); + expect(entries[1]).to.deep.include({ key: 'rsg_version', value: '1.2' }); + expect(entries[1].range.start).to.eql({ line: 1, character: 12 }); + expect(entries[1].range.end).to.eql({ line: 1, character: 15 }); + }); + + it('skips empty lines and comments without breaking line numbers', () => { + const entries = parseManifestEntries(trim` + # comment + + title=t + # another + rsg_version=1.3 + `); + expect(entries).to.have.lengthOf(2); + expect(entries[0].key).to.equal('title'); + expect(entries[0].range.start.line).to.equal(2); + expect(entries[1].key).to.equal('rsg_version'); + expect(entries[1].range.start.line).to.equal(4); + }); + + it('handles CRLF line endings', () => { + //CRLF must be a literal `\r\n` in the source — template-literal whitespace stripping + //cannot reliably produce CRLF, so keep the explicit form here. + const entries = parseManifestEntries(`title=t\r\nrsg_version=1.2\r\n`); + expect(entries).to.have.lengthOf(2); + expect(entries[1].range.start.line).to.equal(1); + }); + + it('throws on lines with no equals sign', () => { + expect(() => parseManifestEntries('not_a_key_value_line')).to.throw(/No '=' detected/); + }); + + it('parseManifest is consistent with parseManifestEntries', () => { + const contents = trim` + title=t + rsg_version=1.2 + bs_const=DEBUG=true + `; + const map = parseManifest(contents); + const entries = parseManifestEntries(contents); + //every entry's key should be in the map with the same value + for (const entry of entries) { + expect(map.get(entry.key)).to.equal(entry.value); + } + //and the map's size matches entry count + expect(map.size).to.equal(entries.length); + }); + }); + describe('bs_const parser', () => { function test(manifest: string, expected) { expect( diff --git a/src/preprocessor/Manifest.ts b/src/preprocessor/Manifest.ts index 03cb0d2a2..eaa4e2382 100644 --- a/src/preprocessor/Manifest.ts +++ b/src/preprocessor/Manifest.ts @@ -1,11 +1,23 @@ import * as fsExtra from 'fs-extra'; import * as path from 'path'; +import type { Range } from 'vscode-languageserver'; +import { util } from '../util'; /** * A map containing the data from a `manifest` file. */ export type Manifest = Map; +/** + * One key/value pair from a manifest, with the source location of the value. + * The range covers the value (right-hand side of `=`), suitable for diagnostic squiggles. + */ +export interface ManifestEntry { + key: string; + value: string; + range: Range; +} + /** * Attempts to read a `manifest` file, parsing its contents into a map of string to JavaScript * number, string, or boolean. @@ -33,8 +45,21 @@ export async function getManifest(rootDir: string): Promise { * representing the manifest file's contents */ export function parseManifest(contents: string) { - const lines = contents.split(/\r?\n/g); const result = new Map(); + for (const entry of parseManifestEntries(contents)) { + result.set(entry.key, entry.value); + } + return result; +} + +/** + * Parse a manifest file's contents into an ordered list of entries with line/column ranges. + * Use this when you need to attach diagnostics to specific manifest lines; for plain key→value + * lookups, prefer {@link parseManifest}. + */ +export function parseManifestEntries(contents: string): ManifestEntry[] { + const lines = contents.split(/\r?\n/g); + const result: ManifestEntry[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // skip empty lines and comments @@ -42,7 +67,7 @@ export function parseManifest(contents: string) { continue; } - let equalIndex = line.indexOf('='); + const equalIndex = line.indexOf('='); if (equalIndex === -1) { throw new Error( `[manifest:${i + 1}] No '=' detected. Manifest attributes must be of the form 'key=value'.` @@ -50,7 +75,9 @@ export function parseManifest(contents: string) { } const key = line.slice(0, equalIndex); const value = line.slice(equalIndex + 1); - result.set(key, value); + //range covers just the value (after `=`) so squiggles point at the meaningful text + const range = util.createRange(i, equalIndex + 1, i, line.length); + result.push({ key: key, value: value, range: range }); } return result; } From de8cae69fe089818571d688e4739cfb2b3cba82a Mon Sep 17 00:00:00 2001 From: Christopher Dwyer-Perkins Date: Tue, 5 May 2026 22:17:54 -0300 Subject: [PATCH 2/2] Generalize callable availability diagnostics + cache version getters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR feedback: - Add Availability/AvailabilityInfo to Callable. Eval is tagged with os.deprecated=9.0.0 and rsg.removed=1.2.0; a single CallExpression visitor walks any global callable's availability. Adding a new callable's lifecycle is just metadata. - The validator emits at most one diagnostic per call: rsg axis takes precedence; os axis fires only when rsg is silent. Avoids duplicate diagnostics on the same line for the common-case modern firmware. - Replace evalIsDeprecatedAtRsgVersion with two generic diagnostics (`globalCallableRemoved`, `globalCallableDeprecated`) parameterized by name + axis + threshold + current. Messages match the existing style of featureRequiresMinFirmwareVersion (threshold AND current target). - Diagnostic codes 1147–1152 are renumbered so they appear in numeric order in DiagnosticMessages.ts. - Mark Callable.isDeprecated as @deprecated; availability supersedes it. - Rename getEffectiveMinFirmwareVersion -> getMinFirmwareVersion. Cache it and getRsgVersion. Both return canonical coerced semver strings so downstream callers don't re-coerce. - Drop redundant semver.coerce calls in ProgramValidator. --- src/DiagnosticMessages.ts | 34 +++++++-- src/Program.spec.ts | 44 ++++++------ src/Program.ts | 58 ++++++++++------ src/RokuConstants.ts | 34 +++++++-- .../validation/BrsFileValidator.spec.ts | 22 +++--- src/bscPlugin/validation/BrsFileValidator.ts | 69 ++++++++++++++----- src/bscPlugin/validation/ProgramValidator.ts | 34 ++++----- src/globalCallables.ts | 7 +- src/interfaces.ts | 11 +++ 9 files changed, 206 insertions(+), 107 deletions(-) diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 31aa85ea4..875d62383 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -771,33 +771,55 @@ export let DiagnosticMessages = { code: 1146, severity: DiagnosticSeverity.Error }), - evalIsDeprecatedAtRsgVersion: (rsgVersion: string) => ({ - message: `'eval' is removed in rsg_version=${rsgVersion}`, + /** + * Callable was marked removed in `availability.os` or `availability.rsg`, and the project's + * effective firmware/rsg_version meets that threshold. The call is a hard error on device. + */ + globalCallableRemoved: (name = '', axis: AvailabilityAxis = 'os', threshold = '', current = '') => ({ + message: `'${name}' is removed in ${formatAvailabilityAxis(axis, threshold)} or higher (current target is ${current})`, code: 1147, + data: { name: name, axis: axis, threshold: threshold, current: current }, severity: DiagnosticSeverity.Error }), + /** + * Callable was marked deprecated in `availability.os` or `availability.rsg`, and the project's + * effective firmware/rsg_version meets that threshold. The call still works but should + * be migrated. + */ + globalCallableDeprecated: (name = '', axis: AvailabilityAxis = 'os', threshold = '', current = '') => ({ + message: `'${name}' is deprecated as of ${formatAvailabilityAxis(axis, threshold)} (current target is ${current})`, + code: 1148, + data: { name: name, axis: axis, threshold: threshold, current: current }, + severity: DiagnosticSeverity.Warning + }), rsgVersionRequiresMinFirmware: (rsgVersion: string, requiredFirmware: string, configuredFirmware: string) => ({ message: `rsg_version=${rsgVersion} requires Roku firmware version ${requiredFirmware} or higher (current target is ${configuredFirmware})`, - code: 1148, + code: 1149, severity: DiagnosticSeverity.Error }), invalidRsgVersionFormat: (value: string) => ({ message: `'${value}' is not a valid rsg_version (expected value like '1.2' or '1.3')`, - code: 1149, + code: 1150, severity: DiagnosticSeverity.Warning }), rsgVersionDeprecated: (rsgVersion: string, suggestedReplacement: string) => ({ message: `rsg_version=${rsgVersion} is deprecated; consider upgrading to rsg_version=${suggestedReplacement}`, - code: 1150, + code: 1151, severity: DiagnosticSeverity.Warning }), rsgVersionRemoved: (rsgVersion: string, removedAt: string, replacement: string) => ({ message: `rsg_version=${rsgVersion} was removed in firmware ${removedAt}; use rsg_version=${replacement}`, - code: 1151, + code: 1152, severity: DiagnosticSeverity.Error }) }; +export type AvailabilityAxis = 'os' | 'rsg'; + +function formatAvailabilityAxis(axis: AvailabilityAxis, version: string): string { + return axis === 'os' ? `Roku OS ${version}` : `rsg_version=${version}`; +} + export const DiagnosticCodeMap = {} as Record; export let diagnosticCodes = [] as number[]; for (let key in DiagnosticMessages) { diff --git a/src/Program.spec.ts b/src/Program.spec.ts index 85bd9ec03..fc038d6b5 100644 --- a/src/Program.spec.ts +++ b/src/Program.spec.ts @@ -3816,35 +3816,35 @@ describe('Program', () => { } }); - describe('getEffectiveMinFirmwareVersion', () => { + describe('getMinFirmwareVersion', () => { it(`returns DEFAULT_MIN_FIRMWARE_VERSION when minFirmwareVersion is unset`, () => { program.dispose(); program = new Program({}); - expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); }); it(`returns the user's value when minFirmwareVersion is set`, () => { program.dispose(); program = new Program({ minFirmwareVersion: '11.5.0' }); - expect(program.getEffectiveMinFirmwareVersion()).to.equal('11.5.0'); + expect(program.getMinFirmwareVersion()).to.equal('11.5.0'); }); it('returns DEFAULT when minFirmwareVersion is set to garbage', () => { program.dispose(); program = new Program({ minFirmwareVersion: 'banana' }); - expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); }); it('returns DEFAULT when minFirmwareVersion is an empty string', () => { program.dispose(); program = new Program({ minFirmwareVersion: '' }); - expect(program.getEffectiveMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); + expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION); }); - it(`returns the user's value when it parses as semver`, () => { + it(`returns the canonical coerced semver form when the user's value parses but is non-strict`, () => { program.dispose(); program = new Program({ minFirmwareVersion: '11.5' }); - expect(program.getEffectiveMinFirmwareVersion()).to.equal('11.5'); + expect(program.getMinFirmwareVersion()).to.equal('11.5.0'); }); }); @@ -3860,49 +3860,49 @@ describe('Program', () => { }); } - it(`returns the manifest's explicit value when set`, () => { + it(`returns the manifest's explicit value coerced to canonical semver`, () => { setupWith(trim` title=t rsg_version=1.1 `); - expect(program.getRsgVersion()).to.equal('1.1'); + expect(program.getRsgVersion()).to.equal('1.1.0'); }); - it(`returns '1.2' when manifest is silent and effective firmware >= 9.3.0 (the default)`, () => { + it(`returns '1.2.0' when manifest is silent and effective firmware >= 9.3.0 (the default)`, () => { setupWith(trim`title=t`); - expect(program.getRsgVersion()).to.equal('1.2'); + expect(program.getRsgVersion()).to.equal('1.2.0'); }); - it(`returns '1.1' when manifest is silent and minFirmwareVersion is between 7.5.0 and 9.3.0`, () => { + it(`returns '1.1.0' when manifest is silent and minFirmwareVersion is between 7.5.0 and 9.3.0`, () => { setupWith(trim`title=t`, '8.0.0'); - expect(program.getRsgVersion()).to.equal('1.1'); + expect(program.getRsgVersion()).to.equal('1.1.0'); }); - it(`returns '1.0' when manifest is silent and minFirmwareVersion is below 7.5.0 (pre-1.1 firmware)`, () => { + it(`returns '1.0.0' when manifest is silent and minFirmwareVersion is below 7.5.0 (pre-1.1 firmware)`, () => { setupWith(trim`title=t`, '7.0.0'); - expect(program.getRsgVersion()).to.equal('1.0'); + expect(program.getRsgVersion()).to.equal('1.0.0'); }); - it(`returns '1.3' when manifest is silent and minFirmwareVersion is >= 15.1.0`, () => { + it(`returns '1.3.0' when manifest is silent and minFirmwareVersion is >= 15.1.0`, () => { //1.3.becameDefaultAt is 15.1.0 (Roku's cert-policy "expected default" for new development at this firmware) setupWith(trim`title=t`, '15.1.0'); - expect(program.getRsgVersion()).to.equal('1.3'); + expect(program.getRsgVersion()).to.equal('1.3.0'); }); - it(`is data-driven: a forward-compat unknown rsg_version is returned verbatim from the manifest`, () => { + it(`is data-driven: a forward-compat unknown rsg_version from the manifest is coerced and returned`, () => { //we don't know about 1.5 in RSG_VERSIONS, but the manifest's explicit value still wins setupWith(trim` title=t rsg_version=1.5 `); - expect(program.getRsgVersion()).to.equal('1.5'); + expect(program.getRsgVersion()).to.equal('1.5.0'); }); it(`falls back to the default firmware (and its rsg_version) when minFirmwareVersion is unparseable garbage`, () => { - //getEffectiveMinFirmwareVersion sanitizes garbage input → DEFAULT (15.0.0) - //→ getRsgVersion picks the highest matching default → '1.2' + //getMinFirmwareVersion sanitizes garbage input → DEFAULT (15.0.0) + //→ getRsgVersion picks the highest matching default → '1.2.0' setupWith(trim`title=t`, 'not-a-version'); - expect(program.getRsgVersion()).to.equal('1.2'); + expect(program.getRsgVersion()).to.equal('1.2.0'); }); }); diff --git a/src/Program.ts b/src/Program.ts index 15d6bcda3..2325814d7 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -1829,49 +1829,63 @@ export class Program { return this._manifestPath; } + private _minFirmwareVersion: string | undefined; + /** * The minimum Roku firmware version brighterscript should assume the user is targeting. - * If `options.minFirmwareVersion` is set AND parseable as semver, that wins; otherwise - * (unset or unparseable) falls back to {@link DEFAULT_MIN_FIRMWARE_VERSION}. This - * guarantees the return is always a coerceable version string, so downstream callers - * never have to handle malformed input. + * If `options.minFirmwareVersion` is set AND parseable as semver, the canonical coerced + * form ("15.0" → "15.0.0") wins; otherwise (unset or unparseable) falls back to + * {@link DEFAULT_MIN_FIRMWARE_VERSION}. The return is always a valid semver string so + * downstream callers can pass it directly to `semver.gte`/`semver.lt` without re-coercing. + * Cached after first call. */ - public getEffectiveMinFirmwareVersion(): string { - const userValue = this.options.minFirmwareVersion; - if (userValue && semver.coerce(userValue)) { - return userValue; + public getMinFirmwareVersion(): string { + if (this._minFirmwareVersion === undefined) { + const userValue = this.options.minFirmwareVersion; + const coerced = userValue ? semver.coerce(userValue) : undefined; + this._minFirmwareVersion = coerced ? coerced.version : DEFAULT_MIN_FIRMWARE_VERSION; } - return DEFAULT_MIN_FIRMWARE_VERSION; + return this._minFirmwareVersion; } + private _rsgVersion: string | undefined; + /** - * Returns the effective `rsg_version` for this program. If the manifest declares a value - * explicitly, that's returned verbatim (including malformed values, so callers can validate - * format themselves). Otherwise, the highest known rsg_version whose `becameDefaultAt` is - * `<=` the effective minimum firmware version is returned — driven entirely by the data in - * {@link RSG_VERSIONS}. + * Returns the effective `rsg_version` for this program in canonical semver form (e.g. "1.2" + * → "1.2.0"). If the manifest declares a value explicitly and it's parseable, the canonical + * form wins; otherwise (manifest silent OR value is malformed) we fall back to the highest + * known rsg_version whose `becameDefaultAt` is `<=` the effective minimum firmware version, + * driven by {@link RSG_VERSIONS}. Cached after first call. + * + * Manifest validation (format errors, etc.) happens in `ProgramValidator` against the raw + * entry — that path doesn't go through this method. */ public getRsgVersion(): string { + if (this._rsgVersion !== undefined) { + return this._rsgVersion; + } const explicit = this.getManifest().get('rsg_version'); - if (explicit !== undefined) { - return explicit.trim(); + const explicitCoerced = explicit !== undefined ? semver.coerce(explicit.trim()) : undefined; + if (explicitCoerced) { + this._rsgVersion = explicitCoerced.version; + return this._rsgVersion; } - //getEffectiveMinFirmwareVersion guarantees a coerceable return, so this never throws - const coercedFw = semver.coerce(this.getEffectiveMinFirmwareVersion())!; //walk known rsg_versions in descending order (newest first) and return the first whose //becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0' //(currently `1.1`), this loop always finds a match. + const minFirmwareVersion = this.getMinFirmwareVersion(); const candidates = Object.entries(RSG_VERSIONS) .filter(([, info]) => info.becameDefaultAt !== undefined) .sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!)); for (const [version, info] of candidates) { - const coercedDefault = semver.coerce(info.becameDefaultAt!); - if (coercedDefault && semver.gte(coercedFw, coercedDefault)) { - return version; + if (semver.gte(minFirmwareVersion, info.becameDefaultAt!)) { + this._rsgVersion = semver.coerce(version)!.version; + return this._rsgVersion; } } //unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0' - return DEFAULT_MIN_FIRMWARE_VERSION; + this._rsgVersion = DEFAULT_MIN_FIRMWARE_VERSION; + return this._rsgVersion; } public dispose() { diff --git a/src/RokuConstants.ts b/src/RokuConstants.ts index 6f81c4d16..42991271f 100644 --- a/src/RokuConstants.ts +++ b/src/RokuConstants.ts @@ -1,3 +1,30 @@ +/** + * Availability markers for a feature on a single version axis (firmware OS, or rsg_version). + * All fields are version strings — coerced to semver at the point of comparison. + * + * Combine with the {@link Availability} container when a feature has availability info on both + * axes (e.g. a callable that was added at firmware X.Y AND removed at rsg_version Z.W). + */ +export interface AvailabilityInfo { + /** First version on this axis at which the feature exists. */ + added?: string; + /** First version on this axis at which use of the feature is discouraged. */ + deprecated?: string; + /** First version on this axis at which the feature stops working as declared. */ + removed?: string; +} + +/** + * Per-axis availability info for a feature. Either side may be absent. A diagnostic fires for + * each axis whose threshold is met by the user's effective values. + */ +export interface Availability { + /** Availability relative to Roku OS firmware versions. */ + os?: AvailabilityInfo; + /** Availability relative to manifest `rsg_version` values. */ + rsg?: AvailabilityInfo; +} + /** * Lifecycle metadata for a single `rsg_version` value. * @@ -100,10 +127,3 @@ export const DEFAULT_MIN_FIRMWARE_VERSION = '15.0.0'; * Source: Roku OS 11 release notes. */ export const OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION = '11.0.0'; - -/** - * The rsg_version at which `eval()` becomes a compile error on device. - * Source: Roku OS 9.0 release notes — `eval()` deprecated when rsg_version=1.2 is set; - * sunset entirely in Roku OS 9.3. - */ -export const EVAL_REMOVED_AT_RSG_VERSION = '1.2.0'; diff --git a/src/bscPlugin/validation/BrsFileValidator.spec.ts b/src/bscPlugin/validation/BrsFileValidator.spec.ts index b9bd0b72e..0fd6baae1 100644 --- a/src/bscPlugin/validation/BrsFileValidator.spec.ts +++ b/src/bscPlugin/validation/BrsFileValidator.spec.ts @@ -644,7 +644,7 @@ describe('BrsFileValidator', () => { `); program.validate(); expectDiagnostics(program, [{ - ...DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2') + ...DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0', '1.2.0') }]); }); @@ -656,7 +656,7 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0').code ); expect(evalDiags).to.be.lengthOf(1); }); @@ -669,12 +669,15 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.3').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0').code ); expect(evalDiags).to.be.lengthOf(1); }); - it('does NOT flag `eval(...)` when manifest declares rsg_version=1.1', () => { + it('flags `eval(...)` via os axis when manifest declares rsg_version=1.1 on modern firmware', () => { + //rsg=1.1 explicit on default firmware (15.0) is an invalid manifest entry — rsg=1.1 + //was removed at OS 14.5. The manifest validator separately flags that. With rsg axis + //silent (1.1 < 1.2), the os.deprecated fallback fires as a secondary nudge. setupProgram({ rsgVersion: '1.1' }); program.setFile('source/main.brs', ` sub main() @@ -682,9 +685,10 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.1').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableDeprecated().code ); - expect(evalDiags).to.be.lengthOf(0); + expect(evalDiags).to.be.lengthOf(1); + expect((evalDiags[0] as any).data?.axis).to.equal('os'); }); it('does NOT flag `eval(...)` when minFirmwareVersion is set below 9.3.0 and manifest is silent', () => { @@ -695,7 +699,7 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.1').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0').code ); expect(evalDiags).to.be.lengthOf(0); }); @@ -719,7 +723,7 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0').code ); expect(evalDiags).to.be.lengthOf(0); }); @@ -732,7 +736,7 @@ describe('BrsFileValidator', () => { end sub `); program.validate(); - const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.evalIsDeprecatedAtRsgVersion('1.2').code + const evalDiags = program.getDiagnostics().filter(d => d.code === DiagnosticMessages.globalCallableRemoved('eval', 'rsg', '1.2.0').code ); expect(evalDiags).to.be.lengthOf(1); }); diff --git a/src/bscPlugin/validation/BrsFileValidator.ts b/src/bscPlugin/validation/BrsFileValidator.ts index d14cd8c97..4b21f1817 100644 --- a/src/bscPlugin/validation/BrsFileValidator.ts +++ b/src/bscPlugin/validation/BrsFileValidator.ts @@ -13,7 +13,9 @@ import { InterfaceType } from '../../types/InterfaceType'; import util from '../../util'; import type { Range } from 'vscode-languageserver'; import * as semver from 'semver'; -import { EVAL_REMOVED_AT_RSG_VERSION, OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION } from '../../RokuConstants'; +import { OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION } from '../../RokuConstants'; +import type { AvailabilityAxis } from '../../DiagnosticMessages'; +import { globalCallableMap } from '../../globalCallables'; export class BrsFileValidator { constructor( @@ -63,7 +65,7 @@ export class BrsFileValidator { if (node.openingParen?.kind === TokenKind.QuestionLeftParen) { this.validateMinFirmwareVersionForOptionalChaining(node.openingParen.range); } - this.validateEvalIsNotDeprecated(node); + this.validateGlobalCallableAvailability(node); }, EnumStatement: (node) => { this.validateDeclarationLocations(node, 'enum', () => util.createBoundingRange(node.tokens.enum, node.tokens.name)); @@ -474,9 +476,8 @@ export class BrsFileValidator { * it is emitted as-is, so the target device must natively support it. */ private validateMinFirmwareVersionForOptionalChaining(range: Range | undefined) { - const minFirmwareVersion = this.event.file.program.getEffectiveMinFirmwareVersion(); - const coercedMinVersion = semver.coerce(minFirmwareVersion); - if (coercedMinVersion && semver.lt(coercedMinVersion, OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION)) { + const minFirmwareVersion = this.event.file.program.getMinFirmwareVersion(); + if (semver.lt(minFirmwareVersion, OPTIONAL_CHAINING_MIN_FIRMWARE_VERSION)) { this.event.file.addDiagnostic({ ...DiagnosticMessages.featureRequiresMinFirmwareVersion( 'optional chaining', @@ -489,27 +490,57 @@ export class BrsFileValidator { } /** - * Add a diagnostic if a CallExpression invokes the bare `eval` builtin under an effective - * rsg_version of 1.2 or higher. On device, this is a compile error (eval was deprecated in - * Roku OS 9.0 with rsg_version=1.2 and removed in 9.3). - * Skips method calls (`m.eval(x)`) and namespaced calls (`alpha.eval(x)`) — only flags the - * bare top-level builtin. + * For a bare top-level call to a known global callable, fire one deprecation/removal + * diagnostic driven by `callable.availability`. The rsg axis takes precedence: if it + * fires, the os axis is skipped entirely. The os axis is only consulted when rsg is + * silent (rsg axis not configured, or effective rsg below its thresholds). + * + * Skips method calls (`m.foo()`) and namespaced calls (`alpha.foo()`) — only the bare + * top-level builtin form resolves to a global callable. */ - private validateEvalIsNotDeprecated(node: CallExpression) { + private validateGlobalCallableAvailability(node: CallExpression) { if (!isVariableExpression(node.callee)) { return; } - if (node.callee.name?.text?.toLowerCase() !== 'eval') { + const calleeName = node.callee.name?.text; + if (!calleeName) { return; } - const rsgVersion = this.event.file.program.getRsgVersion(); - const coercedRsgVersion = semver.coerce(rsgVersion); - if (!coercedRsgVersion || semver.lt(coercedRsgVersion, EVAL_REMOVED_AT_RSG_VERSION)) { + const callable = globalCallableMap.get(calleeName.toLowerCase()); + const availability = callable?.availability; + if (!availability) { return; } - this.event.file.addDiagnostic({ - ...DiagnosticMessages.evalIsDeprecatedAtRsgVersion(rsgVersion), - range: node.callee.range - }); + const rsgDiagnostic = this.computeAvailabilityDiagnostic(calleeName, 'rsg', availability.rsg, this.event.file.program.getRsgVersion()); + const diagnostic = rsgDiagnostic ?? + this.computeAvailabilityDiagnostic(calleeName, 'os', availability.os, this.event.file.program.getMinFirmwareVersion()); + if (diagnostic) { + this.event.file.addDiagnostic({ + ...diagnostic, + range: node.callee.range + }); + } + } + + /** + * Compute (but don't emit) the diagnostic for one axis of {@link Availability}: returns + * `globalCallableRemoved` if the project's effective version is at/past the axis's + * `removed` threshold, otherwise `globalCallableDeprecated` if it's at/past `deprecated`, + * otherwise `undefined`. + * + * `effectiveVersion` is expected in canonical semver form (program getters guarantee this); + * availability constants are authored in canonical form too, so no coercion is needed here. + */ + private computeAvailabilityDiagnostic(calleeName: string, axis: AvailabilityAxis, info: { added?: string; deprecated?: string; removed?: string } | undefined, effectiveVersion: string) { + if (!info) { + return undefined; + } + if (info.removed && semver.gte(effectiveVersion, info.removed)) { + return DiagnosticMessages.globalCallableRemoved(calleeName, axis, info.removed, effectiveVersion); + } + if (info.deprecated && semver.gte(effectiveVersion, info.deprecated)) { + return DiagnosticMessages.globalCallableDeprecated(calleeName, axis, info.deprecated, effectiveVersion); + } + return undefined; } } diff --git a/src/bscPlugin/validation/ProgramValidator.ts b/src/bscPlugin/validation/ProgramValidator.ts index 7a30a15e9..69758d18a 100644 --- a/src/bscPlugin/validation/ProgramValidator.ts +++ b/src/bscPlugin/validation/ProgramValidator.ts @@ -43,7 +43,7 @@ export class ProgramValidator { * from those fields: * - format must be parseable as semver * - cross-check `introducedAt` against effective `minFirmwareVersion` - * - flag versions with a `deprecatedAt` lifecycle field (e.g. 1.0) + * - flag versions with a `deprecatedAt` availability field (e.g. 1.0) * Versions not in the map are treated as "unknown but valid" — no diagnostic. */ private validateManifest() { @@ -61,8 +61,7 @@ export class ProgramValidator { return; } const value = rsgEntry.value.trim(); - const coercedRsg = semver.coerce(value); - if (!coercedRsg) { + if (!semver.coerce(value)) { this.program.addDiagnostics([{ ...DiagnosticMessages.invalidRsgVersionFormat(value), file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, @@ -78,34 +77,29 @@ export class ProgramValidator { return; } - const effectiveFw = this.program.getEffectiveMinFirmwareVersion(); - const coercedFw = semver.coerce(effectiveFw); + //getMinFirmwareVersion returns canonical coerced semver; constants in RSG_VERSIONS are + //hand-written valid semver. No re-coercion needed at this site. + const effectiveFw = this.program.getMinFirmwareVersion(); //removal takes precedence over deprecation. If `removedAt <= effectiveFw`, fire the //removal error and skip the deprecation warning — the manifest entry is no longer honored. - const coercedRemoved = info.removedAt ? semver.coerce(info.removedAt) : undefined; - const removedFiresHere = coercedFw && coercedRemoved && semver.gte(coercedFw, coercedRemoved); - if (removedFiresHere && info.replacement) { + if (info.removedAt && semver.gte(effectiveFw, info.removedAt) && info.replacement) { this.program.addDiagnostics([{ - ...DiagnosticMessages.rsgVersionRemoved(value, info.removedAt!, info.replacement), + ...DiagnosticMessages.rsgVersionRemoved(value, info.removedAt, info.replacement), file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, range: rsgEntry.range }]); - } else if (info.deprecatedAt && info.replacement) { + } else if (info.deprecatedAt && info.replacement && semver.gte(effectiveFw, info.deprecatedAt)) { //fire deprecation only when the effective firmware is >= the deprecation point — projects //targeting pre-deprecation firmware can legitimately keep using the old version. - const coercedDeprecated = semver.coerce(info.deprecatedAt); - if (coercedFw && coercedDeprecated && semver.gte(coercedFw, coercedDeprecated)) { - this.program.addDiagnostics([{ - ...DiagnosticMessages.rsgVersionDeprecated(value, info.replacement), - file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, - range: rsgEntry.range - }]); - } + this.program.addDiagnostics([{ + ...DiagnosticMessages.rsgVersionDeprecated(value, info.replacement), + file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, + range: rsgEntry.range + }]); } - const coercedRequired = semver.coerce(info.introducedAt); - if (coercedFw && coercedRequired && semver.lt(coercedFw, coercedRequired)) { + if (semver.lt(effectiveFw, info.introducedAt)) { this.program.addDiagnostics([{ ...DiagnosticMessages.rsgVersionRequiresMinFirmware(value, info.introducedAt, effectiveFw), file: { srcPath: manifestPath, pkgPath: 'manifest' } as any, diff --git a/src/globalCallables.ts b/src/globalCallables.ts index f73e83e64..2f81e66bd 100644 --- a/src/globalCallables.ts +++ b/src/globalCallables.ts @@ -14,7 +14,7 @@ import util from './util'; export let globalFile = new BrsFile('global', 'global', null); globalFile.parse(''); -type GlobalCallable = Pick; +type GlobalCallable = Pick; let mathFunctions: GlobalCallable[] = [{ name: 'Abs', @@ -284,7 +284,10 @@ let runtimeFunctions: GlobalCallable[] = [{ shortDescription: `Eval can be used to run a code snippet in the context of the current function. It performs a compile, and then the bytecode execution.\nIf a compilation error occurs, no bytecode execution is performed, and Eval returns an roList with one or more compile errors. Each list entry is an roAssociativeArray with ERRNO and ERRSTR keys describing the error.\nIf compilation succeeds, bytecode execution is performed and the integer runtime error code is returned. These are the same error codes as returned by GetLastRunRuntimeError().\nEval() can be usefully in two cases. The first is when you need to dynamically generate code at runtime.\nThe other is if you need to execute a statement that could result in a runtime error, but you don't want code execution to stop. '`, type: new FunctionType(new DynamicType()), file: globalFile, - isDeprecated: true, + availability: { + os: { deprecated: '9.0.0' }, + rsg: { removed: '1.2.0' } + }, params: [{ name: 'code', type: new StringType(), diff --git a/src/interfaces.ts b/src/interfaces.ts index c629aa700..474f79caf 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -16,6 +16,7 @@ import type { AstEditor } from './astUtils/AstEditor'; import type { Token } from './lexer/Token'; import type { SemanticTokenModifiers, SemanticTokenTypes } from 'vscode-languageserver'; import type { SourceFixAllCodeAction } from './CodeActionUtil'; +import type { Availability } from './RokuConstants'; export interface BsDiagnostic extends Diagnostic { file: BscFile; @@ -52,7 +53,17 @@ export interface Callable { * The range of the name of this callable */ nameRange?: Range; + /** + * @deprecated Use `availability` instead, which carries firmware/rsg_version thresholds for + * deprecation and removal. + */ isDeprecated?: boolean; + /** + * Optional availability metadata relative to Roku OS firmware and/or manifest rsg_version. + * When set, the validator emits deprecation/removal diagnostics if the project's effective + * values cross the listed thresholds. + */ + availability?: Availability; getName: (parseMode: ParseMode) => string; /** * Indicates whether or not this callable has an associated namespace