From 2f16dd7c611bc766b075ebcf757e03a924905581 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Sat, 24 Oct 2026 15:09:37 +0100 Subject: [PATCH] fix: skip members whose names aren't identifiers instead of throwing getType() returns null for a class member whose name isn't an identifier: a private field (#secret), a computed (['computed']), quoted ('quoted') or numeric (42) name. Two callers used the result without checking: - getNodeAsAttribute() destructured it, so an @attribute tag on such a member, or such a member in an @interface type, threw "Cannot destructure property 'type' of 'getType(...)' as it is null". - getAttributes() read type.name, so any script class with such a member threw "Cannot read properties of null (reading 'name')". The editor's code editor runs getAttributes() in its esm-script worker for attribute autofill, so a script with a private field crashed the worker on every parse. Both now skip the member, as it can't be an attribute anyway. Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 3 +- src/parsers/attribute-parser.ts | 7 +++- test/fixtures/member-names.valid.js | 47 +++++++++++++++++++++++++++ test/tests/valid/member-names.test.js | 46 ++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/member-names.valid.js create mode 100644 test/tests/valid/member-names.test.js diff --git a/src/index.ts b/src/index.ts index dadfdf5..8d6955b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -286,9 +286,10 @@ export class JSDocParser { const attribute = attributes[scriptName].attributes[name]; const isAttribute = !!attribute; + // null for names that aren't identifiers (#private, ['computed'], 'quoted', 42) const type = getType(member, typeChecker); - if (!SUPPORTED_ATTRIBUTE_TYPES.has(type.name)) { + if (!type || !SUPPORTED_ATTRIBUTE_TYPES.has(type.name)) { continue; } diff --git a/src/parsers/attribute-parser.ts b/src/parsers/attribute-parser.ts index 6eb2bd6..2848e13 100644 --- a/src/parsers/attribute-parser.ts +++ b/src/parsers/attribute-parser.ts @@ -191,7 +191,12 @@ export class AttributeParser { getNodeAsAttribute(node: ts.Node, errors: ParsingError[] = [], scriptName?: string) { const nameNode = (node as InternalNode).name; const name = (nameNode && ts.isIdentifier(nameNode) && nameNode.text) as string; - const { type, name: typeName, array, isMixedUnion } = getType(node, this.typeChecker); + + // null for names that aren't identifiers (#private, ['computed'], 'quoted', 42), which can't be attributes + const nodeType = getType(node, this.typeChecker); + if (!nodeType) return; + + const { type, name: typeName, array, isMixedUnion } = nodeType; const enums = this.getEnumMembers(node, errors); let value: LiteralValue = null; diff --git a/test/fixtures/member-names.valid.js b/test/fixtures/member-names.valid.js new file mode 100644 index 0000000..2bbacf8 --- /dev/null +++ b/test/fixtures/member-names.valid.js @@ -0,0 +1,47 @@ +/* eslint-disable */ +import { Script } from 'playcanvas'; + +/** + * @interface + */ +class Settings { + /** + * @attribute + * @type {number} + */ + level = 1; + + #cache = null; +} + +// Members whose names aren't identifiers (private, computed, quoted, numeric) can't be +// attributes. They must be skipped, not crash parsing of the whole script. +export class Example extends Script { + static scriptName = 'example'; + + /** + * @attribute + * @type {number} + */ + speed = 1; + + /** + * @attribute + * @type {Settings} + */ + settings; + + #secret = 2; + + /** + * @attribute + * @type {number} + */ + #taggedSecret = 3; + + ['computed'] = 4; + + 'quoted' = 5; + + 42 = 6; +} diff --git a/test/tests/valid/member-names.test.js b/test/tests/valid/member-names.test.js new file mode 100644 index 0000000..4ecd816 --- /dev/null +++ b/test/tests/valid/member-names.test.js @@ -0,0 +1,46 @@ +import { expect } from 'chai'; +import { describe, it, before } from 'mocha'; + +import { getAttributes, parseAttributes } from '../../utils.ts'; + +describe('VALID: Members whose names are not identifiers', function () { + describe('parseAttributes', function () { + let data; + before(async function () { + data = await parseAttributes('./member-names.valid.js'); + }); + + it('parses without errors', function () { + expect(data[1]).to.be.empty; + expect(data[0]?.example).to.exist; + expect(data[0].example.errors).to.be.empty; + }); + + it('keeps the attributes with identifier names', function () { + expect(Object.keys(data[0].example.attributes)).to.have.members(['speed', 'settings']); + expect(data[0].example.attributes.speed.type).to.equal('number'); + }); + + it('skips a private member of an interface type', function () { + const schema = data[0].example.attributes.settings.schema; + expect(schema.map(({ name }) => name)).to.deep.equal(['level']); + }); + }); + + describe('getAttributes', function () { + let data; + before(async function () { + data = await getAttributes('./member-names.valid.js'); + }); + + it('parses without errors', function () { + expect(data[1]).to.be.empty; + expect(data[0]?.example).to.exist; + }); + + it('lists only the members with identifier names', function () { + const names = data[0].example.map(({ name }) => name); + expect(names).to.not.include.members(['#secret', '#taggedSecret', "['computed']", "'quoted'", '42']); + }); + }); +});