From 4f38d7fb71f7b5e1b60da3ad61fcb460f2d8668c Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:51:46 -0700 Subject: [PATCH 1/4] extension example inference: detect relevant code by var name or extensions property, detect extensions in initJsPsych and produce error when extension is registered but never used in a trial --- packages/autodoc/src/parsers/extension.ts | 33 +++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index 4e286db..fc3c553 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -97,8 +97,24 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const trialPattern = /^[a-zA-Z_$]*[Tt]rial(_?\d+)?$/; let initStatement: ts.VariableStatement | undefined; let initJsPsychVarName: string | undefined; + let initJsPsychHasExtensions = false; const trialNodes: ts.VariableDeclaration[] = []; + function hasExtensionsProperty(node: ts.Node): boolean { + if (ts.isCallExpression(node)) return false; + if ( + ts.isObjectLiteralExpression(node) && + node.properties.some( + (p) => + ts.isPropertyAssignment(p) && + ts.isIdentifier(p.name) && + p.name.text === "extensions", + ) + ) + return true; + return ts.forEachChild(node, hasExtensionsProperty) ?? false; + } + function visitNodes(node: ts.Node) { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { const init = node.initializer; @@ -113,17 +129,10 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (ts.isVariableStatement(stmt)) { initStatement = stmt; initJsPsychVarName = node.name.text; + initJsPsychHasExtensions = init.arguments.some(hasExtensionsProperty); } - } - - if (trialPattern.test(node.name.text) && init && ts.isObjectLiteralExpression(init)) { - const hasExtensions = init.properties.some( - (p) => - ts.isPropertyAssignment(p) && - ts.isIdentifier(p.name) && - p.name.text === "extensions", - ); - if (hasExtensions) trialNodes.push(node); + } else if (init && (trialPattern.test(node.name.text) || hasExtensionsProperty(init))) { + trialNodes.push(node); } } ts.forEachChild(node, visitNodes); @@ -137,7 +146,9 @@ function inferCodeBlock(sourceContent: string, sourcePath: string): string { if (trialNodes.length === 0) throw new Error( - `${sourcePath}: no trial variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, + initJsPsychHasExtensions + ? `${sourcePath}: extension found in initJsPsych but no trial variables found that use the extension — use jspsych-autodoc:start/end sentinels instead` + : `${sourcePath}: no variables with an "extensions" field found — use jspsych-autodoc:start/end sentinels instead`, ); // build map of all local variable declarations, excluding trial nodes themselves From 1de842cf8208b1187c210b4a57d4f0d9e5b8b581 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:52:48 -0700 Subject: [PATCH 2/4] update inferCodeBlock JSDoc comment to reflect new logic (either trial var name or extensions property) --- packages/autodoc/src/parsers/extension.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/autodoc/src/parsers/extension.ts b/packages/autodoc/src/parsers/extension.ts index fc3c553..ed63b3e 100644 --- a/packages/autodoc/src/parsers/extension.ts +++ b/packages/autodoc/src/parsers/extension.ts @@ -75,9 +75,10 @@ export function getExtensionInfo( /** * Fallback code block extractor for HTML extension example files without sentinels. Requires * exactly one inline script block. Extracts the `initJsPsych` call and all trial variables - * (camel/snake case) whose object literal contains an `extensions` field. For each matched - * trial, its direct local dependencies (one level of indirection) are also included. - * The initJsPsych variable itself is never treated as a dependency. + * (any variable name that matches the "trial" pattern in camel/snake case) OR whose object + * literal contains an `extensions` field. For each matched trial, its direct local + * dependencies (one level of indirection) are also included. The initJsPsych variable itself + * is never treated as a dependency. */ function inferCodeBlock(sourceContent: string, sourcePath: string): string { const scriptRegex = /]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi; From 94a2991a0b5be253605d2861af3f6ee8b921a77b Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 16:54:49 -0700 Subject: [PATCH 3/4] add tests for extension example code parsing and infer-tests directory with more test example files --- .../infer-tests/extension-in-init-only.html | 22 +++++ .../infer-tests/mixed-detection.html | 24 ++++++ .../no-trial-name-or-extension.html | 18 ++++ .../non-trial-name-with-extension.html | 21 +++++ .../infer-tests/trial-name-no-extension.html | 20 +++++ .../infer-tests/trial-name-non-object.html | 15 ++++ .../trial-name-with-extension.html | 21 +++++ .../autodoc/tests/parsers/extension.test.ts | 84 +++++++++++++++++++ 8 files changed, 225 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html new file mode 100644 index 0000000..52b5320 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/extension-in-init-only.html @@ -0,0 +1,22 @@ + + + + init only extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html new file mode 100644 index 0000000..d544400 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/mixed-detection.html @@ -0,0 +1,24 @@ + + + + mixed detection example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html new file mode 100644 index 0000000..573ad97 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/no-trial-name-or-extension.html @@ -0,0 +1,18 @@ + + + + no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html new file mode 100644 index 0000000..bf221a6 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/non-trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + non trial name example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html new file mode 100644 index 0000000..0e1c836 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-no-extension.html @@ -0,0 +1,20 @@ + + + + trial name no extension example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html new file mode 100644 index 0000000..7a213c0 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-non-object.html @@ -0,0 +1,15 @@ + + + + trial name non object example + + + + + diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html new file mode 100644 index 0000000..1a6c2a1 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/trial-name-with-extension.html @@ -0,0 +1,21 @@ + + + + trial name example + + + + + diff --git a/packages/autodoc/tests/parsers/extension.test.ts b/packages/autodoc/tests/parsers/extension.test.ts index 2230122..6916dd4 100644 --- a/packages/autodoc/tests/parsers/extension.test.ts +++ b/packages/autodoc/tests/parsers/extension.test.ts @@ -93,6 +93,90 @@ describe('getExtensionInfo', () => { }); }); +describe('inferCodeBlock (via getExtensionInfoAndExamples)', () => { + const inferTestsDir = path.resolve(__dirname, '../fixtures/extension/infer-tests'); + let classNode: ts.ClassDeclaration; + + beforeAll(() => { + classNode = identifyPackageType(fixtureSource).mainNode as ts.ClassDeclaration; + }); + + it('throws when no extension is found anywhere and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'no-trial-name-or-extension.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('no variables with an "extensions" field found'); + }); + + it('throws when extension is found only in initJsPsych and no trial variable name', () => { + const filePath = path.join(inferTestsDir, 'extension-in-init-only.html'); + expect(() => getExtensionInfoAndExamples(fixtureSource, classNode, filePath)) + .toThrow('extension found in initJsPsych but no trial variables found that use the extension'); + }); + + it('succeeds when trial object has extension and name does not match trial name regex', () => { + const filePath = path.join(inferTestsDir, 'non-trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['non trial name example']).toBeDefined(); + const code = info.examples['non trial name example'].code; + expect(code).toContain('myBlock'); + expect(code).toContain('initJsPsych'); + }); + + it('succeeds when trial object has extension and uses trial name regex and does not duplicate', () => { + const filePath = path.join(inferTestsDir, 'trial-name-with-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name example']).toBeDefined(); + const code = info.examples['trial name example'].code; + expect(code).toContain('const trial'); + expect((code.match(/const trial\b/g) ?? []).length).toBe(1); + }); + + it('succeeds when trial name matches regex but trial object has no extensions property', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['trial name no extension example']).toBeDefined(); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('initJsPsych'); + }); + + it('collects local dependencies of a trial found by name (no extensions property)', () => { + const filePath = path.join(inferTestsDir, 'trial-name-no-extension.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + const code = info.examples['trial name no extension example'].code; + expect(code).toContain('const stimulus'); + expect(code.indexOf('const stimulus')).toBeLessThan(code.indexOf('const trial')); + }); + + it('includes variables detected by both name and extensions in the same example', () => { + const filePath = path.join(inferTestsDir, 'mixed-detection.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['mixed detection example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('const myBlock'); + }); + + it('includes a trial-named variable even when its initializer is not an object', () => { + const filePath = path.join(inferTestsDir, 'trial-name-non-object.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + const code = info.examples['trial name non object example'].code; + expect(code).toContain('const trial'); + expect(code).toContain('"experiment stimulus text"'); + }); + + it('does not throw when start/end sentinels are used even if inferCodeBlock would fail', () => { + const filePath = path.join(inferTestsDir, 'sentinel-bypass.html'); + const info = getExtensionInfoAndExamples(fixtureSource, classNode, filePath); + expect(Object.keys(info.examples)).toHaveLength(1); + expect(info.examples['sentinel bypass example']).toBeDefined(); + }); +}); + describe('getExtensionInfoAndExamples', () => { const examplesDir = path.resolve(__dirname, '../fixtures/extension/examples'); From 5dd528a355b3b9f01ac8d83461972a460582ba57 Mon Sep 17 00:00:00 2001 From: Becky Gilbert Date: Mon, 6 Jul 2026 17:00:18 -0700 Subject: [PATCH 4/4] add missing test example file --- .../infer-tests/sentinel-bypass.html | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html diff --git a/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html new file mode 100644 index 0000000..54093a4 --- /dev/null +++ b/packages/autodoc/tests/fixtures/extension/infer-tests/sentinel-bypass.html @@ -0,0 +1,20 @@ + + + + + + + + +