diff --git a/scripts/verify-plugin-record-callers.spec.ts b/scripts/verify-plugin-record-callers.spec.ts index 0f1f2ad417..798f7b0e7e 100644 --- a/scripts/verify-plugin-record-callers.spec.ts +++ b/scripts/verify-plugin-record-callers.spec.ts @@ -9,6 +9,7 @@ import { isAdmittedCaller, OWNER_FILE, readRepositorySources, + unescaped, } from './verify-plugin-record-callers.ts' const roots: string[] = [] @@ -56,6 +57,8 @@ describe('verify-plugin-record-callers', () => { ['a hexadecimal escape in an element access', "S['\\x61ppendPluginRecord'](session, 'plugin:a', {})"], ['a code point escape in an element access', "S['\\u{61}ppendPluginRecord'](session, 'plugin:a', {})"], ['needless character escapes in an element access', "S['\\a\\ppendPluginRecord'](session, 'plugin:a', {})"], + ['a legacy octal escape in an element access', "S['\\141ppendPluginRecord'](session, 'plugin:a', {})"], + ['a three-digit legacy octal escape for the last character, beside a two-digit one that spells no reference', "S['appendPluginRecor\\144'](session, 'plugin:a\\61', {})"], ['an escaped template element access', 'S[`\\x61ppendPluginRecord`](session, `plugin:a`, {})'], ])('rejects %s in release package source', (_form, source) => { expect(checkPluginRecordCallers(corpus(`${source}\n`))).toEqual([{ file: RELEASE, line: 1, text: source }]) @@ -79,6 +82,15 @@ describe('verify-plugin-record-callers', () => { it('reports nothing for escapes that spell no reference, including one that names no code point', () => { const source = "const path = 'C:\\\\temp\\\\appendPlugin'\nconst big = '\\u{110000}'\nconst newline = 'a\\nb'\n" expect(findPluginRecordReferences(RELEASE, source)).toEqual([]) + // A file the prefilter unescapes can still hold no reference: this escape spells no letter of the name. + expect(findPluginRecordReferences('packages/core/x/src/legacy.cjs', "S['\\4141ppendPluginRecord']\n")).toEqual([]) + // The AST compares exact string values: " 0appendPluginRecord", as TypeScript decodes this, names nothing. + expect(findPluginRecordReferences('packages/core/x/src/legacy.cjs', "S['\\400appendPluginRecord']\n")).toEqual([]) + }) + + it('unescapes legacy octal escapes as TypeScript decodes them, taking three digits only after 0 to 3', () => { + // \400 exceeds 0o377, so it is \40 then a literal 0; \141 and \61 stay whole. + expect([unescaped("'\\400'"), unescaped("'\\141\\61'"), unescaped("'\\0'"), unescaped("'\\777'")]).toEqual(["' 0'", "'a1'", "'\0'", "'?7'"]) }) it('ignores comments, JSDoc links, longer strings, other identifiers, and wildcard re-exports', () => { diff --git a/scripts/verify-plugin-record-callers.ts b/scripts/verify-plugin-record-callers.ts index ee14caf048..6eaac27469 100644 --- a/scripts/verify-plugin-record-callers.ts +++ b/scripts/verify-plugin-record-callers.ts @@ -13,7 +13,7 @@ * (`export * from`) names no identifier and is not reported; code that calls the function through * it still names the function, and that reference is. A file is parsed when its text, or its text * with its escapes unescaped, contains the name, so a spelling that escapes part of it (`\u0061`, - * `\x61`, `\u{61}`, `\a`, a line continuation) is still found. + * `\x61`, `\u{61}`, `\141`, `\a`, a line continuation) is still found. */ import { execFileSync } from 'node:child_process' @@ -47,19 +47,26 @@ const LINE_CONTINUATION = /\\(?:\r\n|[\n\r\u2028\u2029])/gu const HEX_ESCAPE = /\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2}))/gu /** - * The source as its escapes could spell it: line continuations dropped, hex escapes decoded, and - * every other backslash removed. Text that the parser reads as the restricted name, in an - * identifier or a string, contains the name here too. + * A legacy octal escape, which a sloppy-mode script such as a `.cjs` file may use: up to three + * octal digits, three only when the first is 0–3, so the value stays within 0o377. + */ +const OCTAL_ESCAPE = /\\([0-3][0-7]{0,2}|[4-7][0-7]?)/gu + +/** + * The source as its escapes could spell it: line continuations dropped, hex and legacy octal + * escapes decoded, and every other backslash removed. Text that the parser reads as the + * restricted name, in an identifier or a string, contains the name here too. * @param source - file contents. * @returns the unescaped text. */ -function unescaped(source: string): string { +export function unescaped(source: string): string { return source .replace(LINE_CONTINUATION, '') .replace(HEX_ESCAPE, (match: string, braced?: string, unit?: string, byte?: string) => { const code = Number.parseInt(`${braced ?? ''}${unit ?? ''}${byte ?? ''}`, 16) return code <= 0x10ffff ? String.fromCodePoint(code) : match }) + .replace(OCTAL_ESCAPE, (_match: string, digits: string) => String.fromCharCode(Number.parseInt(digits, 8))) .replaceAll('\\', '') }