@agent-scope/babel-plugin
v1.21.11
Published
Babel plugin for automatic Scope instrumentation via AST transforms
Readme
@agent-scope/babel-plugin
Build-time AST transform that injects two static properties into every React component definition: __scopeSource (file + line + column) and __scopeProps (TypeScript prop type metadata). No runtime overhead — all analysis happens at compile time.
Installation
npm install --save-dev @agent-scope/babel-plugin
# or
bun add -d @agent-scope/babel-pluginWhat it does
For each detected React component the plugin emits two assignment statements immediately after the component definition:
// Input
interface ButtonProps {
variant: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
children: React.ReactNode;
onClick?: (e: MouseEvent) => void;
}
function Button({ variant, size = 'md', disabled = false, children, onClick }: ButtonProps) {
return <button type="button" disabled={disabled}>{children}</button>;
}
// Output (after transform)
function Button({ variant, size = 'md', disabled = false, children, onClick }) {
return /*#__PURE__*/React.createElement("button", { type: "button", disabled }, children);
}
Button.__scopeSource = { filePath: "src/Button.tsx", line: 9, column: 0 };
Button.__scopeProps = {
variant: { type: "union", required: true, values: ["primary", "secondary", "ghost"] },
size: { type: "union", required: false, values: ["sm", "md", "lg"], defaultValue: "md" },
disabled: { type: "boolean", required: false, defaultValue: false },
children: { type: "ReactNode", required: true },
onClick: { type: "function", required: false },
};Architecture
Program.enter
└── Pre-collect all TS interface / type alias declarations
into a WeakMap<Program, Map<typeName, TSTypeElement[]>>
(before @babel/preset-typescript strips them)
FunctionDeclaration / VariableDeclaration / ExportDefaultDeclaration
└── component-detector.ts
├── isComponentName() → name starts with uppercase
└── bodyContainsJSX() → body has a return statement returning JSX
│
▼
source-injector.ts
└── buildScopeSourceStatement() → AST node for __scopeSource = {...}
prop-type-extractor.ts
└── resolveTypeReferenceName() → look up interface/type in pre-collected map
extractPropsFromTypeMembers() → PropMap
extractDefaultValues() → defaults from destructuring
│
▼
props-injector.ts
└── buildScopePropsStatement() → AST node for __scopeProps = {...}Why Program.enter pre-collection?
@babel/preset-typescript removes TSInterfaceDeclaration and TSTypeAliasDeclaration nodes during the same traversal pass. Because Babel traverses top-down, interface declarations (defined before the component) are visited and removed before the FunctionDeclaration visitor fires. The plugin collects all type declarations at Program.enter — before any child nodes are visited or removed — and stores them in a WeakMap keyed by the Program node.
Component detection rules
A function is treated as a React component if:
- Name starts with an uppercase letter —
isComponentName(name)checks/^[A-Z]/ - Body returns JSX —
bodyContainsJSX()checks for aReturnStatementwhose argument is aJSXElementorJSXFragment(including via ternary and logical expressions one level deep)
Detected patterns:
| Pattern | Example |
|---|---|
| Named function declaration | function Button() { return <div/>; } |
| Arrow function variable | const Card = () => <div/>; |
| Function expression variable | const Card = function() { return <div/>; }; |
| React.memo() wrapper | const Memo = React.memo(() => <div/>); |
| React.forwardRef() wrapper | const Ref = React.forwardRef((ref, p) => <input ref={ref}/>); |
| Named default export | export default function Page() { return <main/>; } |
| Anonymous default export (arrow/fn) | export default () => <div/>; |
Not detected (intentionally excluded):
- Lowercase-named functions:
function formatDate(d)→ skipped - React hooks:
function useCounter()→ skipped (lowercase 'u') - Functions that don't return JSX:
function GetData() { return fetch(...); }→ skipped
__scopeSource injection
Injected shape
ComponentName.__scopeSource = {
filePath: string; // relative path (or opts.filePath override)
line: number; // 1-based line number of the component declaration
column: number; // 0-based column number
};filePath resolution (from plugin.test.ts)
Pass filePath in plugin options to override Babel's automatic filename. This is critical in test environments where absolute paths vary by machine:
// babel.config.js
module.exports = {
plugins: [['@agent-scope/babel-plugin', { filePath: 'src/Button.tsx' }]],
};Without filePath, Babel's state.filename is used (often an absolute path).
__scopeProps injection
Injected shape
ComponentName.__scopeProps = {
[propName: string]: {
type: PropTypeString; // simplified type category
required: boolean; // true if prop has no '?'
values?: string[]; // only for 'union' type — the literal string values
defaultValue?: unknown; // extracted from destructuring default, if present
};
};PropTypeString values
| Value | TypeScript types that map to it |
|---|---|
| "string" | string |
| "number" | number |
| "boolean" | boolean |
| "union" | A union of all string/number literals: 'a' \| 'b' \| 'c' |
| "function" | () => void, MouseEvent, ChangeEvent, React.*Handler |
| "ReactNode" | React.ReactNode, ReactNode, ReactElement, React.ReactElement |
| "object" | { ... } (TSTypeLiteral), mapped types |
| "array" | T[], tuples |
| "unknown" | Intersection, conditional, generic references, any, never, etc. |
Default value extraction
Destructuring defaults are extracted from the first parameter:
function Button({ size = 'md', disabled = false, count = 0 }: ButtonProps) { ... }
// → size.defaultValue: "md", disabled.defaultValue: false, count.defaultValue: 0Supported literal default types: string, number, boolean, null, and negative numbers (-1).
Configuration
babel.config.js
module.exports = {
plugins: ['@agent-scope/babel-plugin'],
};babel.config.js with filePath override
module.exports = {
plugins: [
['@agent-scope/babel-plugin', { filePath: 'src/MyComponent.tsx' }],
],
};Vite (vite.config.ts)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['@agent-scope/babel-plugin'],
},
}),
],
});Next.js (next.config.js)
module.exports = {
experimental: { forceSwcTransforms: false },
babel: {
plugins: ['@agent-scope/babel-plugin'],
},
};Transform examples (from test fixtures)
Simple function component (simple-component.tsx)
Input
function Button({ label }: { label: string }) {
return <button type="button">{label}</button>;
}
export { Button };Output (TypeScript stripped, JSX transformed)
function Button({ label }) {
return /*#__PURE__*/React.createElement("button", { type: "button" }, label);
}
Button.__scopeSource = { filePath: "src/simple-component.tsx", line: 1, column: 0 };
Button.__scopeProps = {
label: { type: "string", required: true },
};Arrow function component (arrow-component.tsx)
Input
const Card = ({ title }: { title: string }) => <div className="card">{title}</div>;
export { Card };Output
const Card = ({ title }) => /*#__PURE__*/React.createElement("div", { className: "card" }, title);
Card.__scopeSource = { filePath: "src/arrow-component.tsx", line: 1, column: 0 };
Card.__scopeProps = {
title: { type: "string", required: true },
};React.memo wrapper (memo-component.tsx)
Input
import React from "react";
const MemoButton = React.memo(({ label }: { label: string }) => (
<button type="button">{label}</button>
));
export { MemoButton };Output
const MemoButton = React.memo(({ label }) =>
/*#__PURE__*/React.createElement("button", { type: "button" }, label)
);
MemoButton.__scopeSource = { filePath: "src/memo-component.tsx", line: 3, column: 0 };
MemoButton.__scopeProps = { label: { type: "string", required: true } };React.forwardRef wrapper (forwardref-component.tsx)
Input
import React from "react";
const FancyInput = React.forwardRef<HTMLInputElement, { placeholder: string }>(
({ placeholder }, ref) => <input ref={ref} placeholder={placeholder} />,
);
export { FancyInput };Output
const FancyInput = React.forwardRef(({ placeholder }, ref) =>
/*#__PURE__*/React.createElement("input", { ref, placeholder })
);
FancyInput.__scopeSource = { filePath: "src/forwardref-component.tsx", line: 3, column: 0 };Named default export (default-export.tsx)
Input
export default function HomePage() {
return <main>Hello World</main>;
}Output
export default function HomePage() {
return /*#__PURE__*/React.createElement("main", null, "Hello World");
}
HomePage.__scopeSource = { filePath: "src/default-export.tsx", line: 1, column: 0 };
// __scopeSource injected exactly onceFull typed component (typed-function-component.tsx)
Input
interface ButtonProps {
variant: "primary" | "secondary" | "ghost";
size?: "sm" | "md" | "lg";
disabled?: boolean;
children: React.ReactNode;
onClick?: (e: MouseEvent) => void;
}
function Button({
variant: _variant,
size: _size = "md",
disabled = false,
children,
onClick: _onClick,
}: ButtonProps) {
return <button type="button" disabled={disabled}>{children}</button>;
}
export { Button };Output (__scopeProps excerpt)
Button.__scopeSource = { filePath: "src/button.tsx", line: 9, column: 0 };
Button.__scopeProps = {
variant: { type: "union", required: true, values: ["primary", "secondary", "ghost"] },
size: { type: "union", required: false, values: ["sm", "md", "lg"], defaultValue: "md" },
disabled: { type: "boolean", required: false, defaultValue: false },
children: { type: "ReactNode", required: true },
onClick: { type: "function", required: false },
};React.FC<Props> arrow component (typed-arrow-fc.tsx)
Input
import type React from "react";
interface CardProps {
title: string;
count: number;
active?: boolean;
}
const Card: React.FC<CardProps> = ({ title, count = 0, active = false }) => (
<div className={active ? "active" : ""}>{title}: {count}</div>
);
export { Card };Output (__scopeProps excerpt)
Card.__scopeSource = { filePath: "src/card.tsx", line: 9, column: 0 };
Card.__scopeProps = {
title: { type: "string", required: true },
count: { type: "number", required: true, defaultValue: 0 },
active: { type: "boolean", required: false, defaultValue: false },
};No-props component (no-props-component.tsx)
Input
function Header() {
return <header>Hello World</header>;
}
export { Header };Output
function Header() {
return /*#__PURE__*/React.createElement("header", null, "Hello World");
}
Header.__scopeSource = { filePath: "src/header.tsx", line: 1, column: 0 };
// __scopeProps is NOT injected — no typed propsUnion type extraction (from prop-extraction.test.ts)
type StatusProps = { status: 'active' | 'inactive' | 'pending' };
function StatusBadge({ status }: StatusProps) { return <span>{status}</span>; }Output
StatusBadge.__scopeProps = {
status: { type: "union", required: true, values: ["active", "inactive", "pending"] },
};Inline type annotation (from prop-extraction.test.ts)
function Avatar({ src, alt }: { src: string; alt?: string }) {
return <img src={src} alt={alt} />;
}Output
Avatar.__scopeProps = {
src: { type: "string", required: true },
alt: { type: "string", required: false },
};Complex types fall back to "unknown" (from prop-extraction.test.ts)
interface ComplexProps { combo: A & B; } // → type: "unknown" (intersection)
interface GenericProps { data: Array<string>; } // → type: "unknown" (generic TSTypeReference)Plugin options
interface PluginOptions {
/** Override the file path injected into __scopeSource.
* Defaults to Babel's `state.filename`.
* Pass this explicitly in tests to avoid absolute-path mismatches. */
filePath?: string;
}Test setup best practice (from KB: Babel Plugin Test Assertions): Always pass filePath as a plugin option in tests and assert against relative paths. Relying on Babel's automatic filename resolution produces absolute paths that break across machines:
transformSync(code, {
plugins: [['@agent-scope/babel-plugin', { filePath: 'src/Button.tsx' }]],
});
expect(output).toContain('filePath: "src/Button.tsx"'); // ✓ portableUsed by
@agent-scope/runtime— readsComponent.__scopeSourceandComponent.__scopePropsat runtime to enrichPageReportnodes with source location and prop type metadata@agent-scope/cli— uses prop type metadata to drivescope rendermatrix axis generation
