@agent-scope/manifest
v1.20.0
Published
TypeScript AST parser that generates a machine-readable React component registry for Scope
Readme
@agent-scope/manifest
TypeScript AST parser that generates a machine-readable React component registry (manifest) from source code.
Walks TypeScript/TSX source files using ts-morph, extracts every React component (function, arrow, class), resolves props types, detects hooks, context dependencies, side effects, renders complexity, and builds a full bi-directional composition tree.
Installation
npm install @agent-scope/manifestWhat it does / when to use it
| Need | Use |
|------|-----|
| Generate a JSON registry of all React components in a project | generateManifest(config) |
| Know which components are composed by / compose which others | manifest.tree |
| Determine if a component can be rendered via Satori (SVG) or needs a browser | complexityClass |
| Find which React contexts a component depends on | requiredContexts |
| Know which hooks a component calls | detectedHooks |
| Detect fetch calls, timers, event listener subscriptions | sideEffects |
| Understand all props (types, defaults, required status) | props in ComponentDescriptor |
Manifest JSON schema
Top-level Manifest
interface Manifest {
version: "0.1"; // schema version
generatedAt: string; // ISO 8601 timestamp
components: Record<string, ComponentDescriptor>; // keyed by component name
tree: Record<string, TreeNode>; // keyed by component name
}ComponentDescriptor — every field
interface ComponentDescriptor {
// -----------------------------------------------------------------------
// Identity
// -----------------------------------------------------------------------
filePath: string; // relative path from rootDir, e.g. "src/components/Button.tsx"
exportType: ExportType; // "named" | "default" | "none"
displayName: string; // displayName property if set, otherwise function/class name
loc: { start: number; end: number }; // 1-based line numbers in the source file
// -----------------------------------------------------------------------
// Props
// -----------------------------------------------------------------------
props: Record<string, PropDescriptor>; // keyed by prop name
// -----------------------------------------------------------------------
// Composition
// -----------------------------------------------------------------------
composes: string[]; // component names rendered directly in this component's JSX
composedBy: string[]; // component names that render this component in their JSX
// -----------------------------------------------------------------------
// Wrappers
// -----------------------------------------------------------------------
forwardedRef: boolean; // true if wrapped with React.forwardRef
memoized: boolean; // true if wrapped with React.memo
hocWrappers: string[]; // HOC wrapper names (excluding memo/forwardRef)
// -----------------------------------------------------------------------
// Phase 6: rendering & runtime analysis
// -----------------------------------------------------------------------
complexityClass: ComplexityClass; // "simple" | "complex"
requiredContexts: string[]; // sorted context variable names (e.g. ["AuthCtx", "ThemeCtx"])
detectedHooks: string[]; // sorted hook names (e.g. ["useCallback", "useEffect", "useState"])
sideEffects: SideEffects;
}PropDescriptor
interface PropDescriptor {
type: PropKind; // resolved TypeScript type category
values?: string[]; // for union types: expanded literal values, e.g. ["primary", "secondary"]
default?: string; // default value as source-code string, e.g. "'primary'"
required: boolean; // false if prop is optional (?) or has a default
rawType: string; // raw TypeScript type string from source
}
type PropKind =
| "string" | "number" | "boolean"
| "union" | "object" | "array"
| "function" | "node" | "element"
| "any" | "unknown" | "never"
| "null" | "undefined" | "literal" | "other";SideEffects
interface SideEffects {
fetches: string[]; // detected fetch callee names: "fetch", "axios", "useQuery", etc.
timers: boolean; // setTimeout / setInterval / requestAnimationFrame detected
subscriptions: string[]; // subscription methods: "subscribe", "onSnapshot", "addListener", etc.
globalListeners: boolean; // window.addEventListener or document.addEventListener detected
}TreeNode
interface TreeNode {
children: string[]; // names of components this component renders directly
parents: string[]; // names of components that render this component directly
}How complexity classification works
Each component receives a complexityClass of "simple" or "complex". The classification drives rendering strategy:
simple— flexbox-only layout, standard box model, no animations. Safe to render via Satori (SVG-based renderer, fast).complex— must render via a full browser pool. Slower but handles all CSS.
Static CSS analysis
The parser scans all inline style={{ ... }} objects in JSX for these triggers:
| Trigger | Example | Result |
|---------|---------|--------|
| CSS Grid properties | gridTemplateColumns, gridArea, grid | complex |
| Absolute / fixed / sticky positioning | position: "absolute" | complex |
| CSS animations | animation, animationName, animationDuration | complex |
| CSS transitions | transition | complex |
| CSS transforms | transform, transformOrigin | complex |
| clipPath, willChange, contain | any of these keys | complex |
| Styled-components / css template literals | styled.div`...`, css`...` | complex |
| Opaque className references | className={styles.root} (identifier/call) | complex |
Anything not matching the above stays "simple".
Class components always default to "complex" (safe fallback; lifecycle methods / state patterns are too complex to analyze statically).
Complexity propagation through the composition tree
After all components are classified, complexity propagates upward through the tree:
A component is
simpleonly if it and every descendant are alsosimple. If any child anywhere in the subtree iscomplex, all ancestors are also markedcomplex.
Algorithm: bottom-up BFS starting from every initially-complex component, following composedBy links upward.
Example from the basic-tree fixture:
App (simple own CSS)
└── Layout (simple own CSS)
└── Sidebar (complex — uses `transition: "width 0.2s"`)
└── NavItem (simple leaf — no complex CSS)After propagation:
NavItem→simple(leaf, no complex descendants)Sidebar→complex(own CSS triggers it)Layout→complex(propagated from Sidebar)App→complex(propagated from Layout → Sidebar)
Propagation unit-test examples (from manifest.test.ts):
// Direct propagation
// Input: Parent(simple) ← Child(complex)
// Result: Parent becomes complex
propagateComplexity({ Parent: { complexityClass: "simple", composedBy: [] },
Child: { complexityClass: "complex", composedBy: ["Parent"] } });
// Parent.complexityClass === "complex"
// Transitive: A → B → C where C is complex → A and B both become complex
// Diamond: A → B, A → C, both B and C → D(complex) → A, B, C all become complexContext detection
The parser detects which React contexts a component depends on via two strategies:
1. Direct useContext() calls
// Inside a component body:
const theme = useContext(ThemeCtx);
// → requiredContexts: ["ThemeCtx"]2. Custom hook resolution
// Component calls a custom hook:
const theme = useTheme();
// In hooks/useTheme.ts:
export function useTheme() {
return useContext(ThemeCtx); // ← resolved transitively
}
// → requiredContexts: ["ThemeCtx"]The parser follows relative imports ("./hooks/useTheme") and scans the hook's source file for useContext(...) calls. Results are de-duplicated and sorted alphabetically.
deep-context fixture — 5 custom hooks each wrapping a different context:
// DeepConsumer calls: useTheme, useAuth, useLocale, useFeatureFlags, useUserPrefs
manifest.components.DeepConsumer.requiredContexts;
// ["AuthCtx", "FeatureFlagsCtx", "LocaleCtx", "ThemeCtx", "UserPrefsCtx"]
manifest.components.DeepConsumer.detectedHooks;
// ["useAuth", "useFeatureFlags", "useLocale", "useTheme", "useUserPrefs"]Side-effect detection
All CallExpression nodes in the component body are scanned recursively (including inside useEffect, useCallback, event handlers, etc.).
| Category | Detected callees |
|----------|-----------------|
| fetches | fetch, axios.*, useQuery, useMutation, useSWR, useInfiniteQuery, request |
| timers | setTimeout, setInterval, requestAnimationFrame, clearTimeout, clearInterval, cancelAnimationFrame |
| subscriptions | .subscribe(), .onSnapshot(), .on(), .listen(), .addListener() |
| globalListeners | window.addEventListener, document.addEventListener, window.removeEventListener, document.removeEventListener, bare addEventListener |
fetches and subscriptions are de-duplicated sorted arrays. timers and globalListeners are booleans.
Example from the hooks-showcase fixture:
// UseEffectDemo uses setInterval inside useEffect
manifest.components.UseEffectDemo.sideEffects.timers; // true
// UseStateDemo has no side effects
manifest.components.UseStateDemo.sideEffects;
// { fetches: [], timers: false, subscriptions: [], globalListeners: false }generateManifest(config)
function generateManifest(config: ManifestConfig): ManifestGenerates the full component manifest by parsing all matching TypeScript/TSX files.
ManifestConfig
interface ManifestConfig {
rootDir: string; // absolute path to project/package root
include?: string[]; // glob patterns — default: ["src/**/*.tsx", "src/**/*.ts"]
exclude?: string[]; // glob patterns — default: ["**/node_modules/**", "**/*.test.*",
// "**/*.spec.*", "**/dist/**", "**/*.d.ts"]
tsConfigFilePath?: string; // path to tsconfig.json — default: "<rootDir>/tsconfig.json"
}Usage
import { generateManifest } from "@agent-scope/manifest";
const manifest = generateManifest({
rootDir: "/path/to/my-project",
});
// Access a specific component
const button = manifest.components.Button;
console.log(button.props.variant);
// { type: "union", values: ["primary", "secondary", "ghost"], required: false, default: "'primary'", rawType: "Variant" }
console.log(button.complexityClass); // "simple" or "complex"
console.log(button.detectedHooks); // ["useCallback", "useState"]
console.log(button.composedBy); // ["Toolbar", "Form"]
console.log(button.composes); // ["Icon", "Spinner"]
// Walk the composition tree
const tree = manifest.tree.Button;
// { children: ["Icon", "Spinner"], parents: ["Toolbar", "Form"] }Custom include/exclude
const manifest = generateManifest({
rootDir: "/path/to/my-project",
include: ["src/components/**/*.tsx", "src/features/**/*.tsx"],
exclude: [
"**/node_modules/**",
"**/*.test.*",
"**/*.stories.*",
"**/dist/**",
],
tsConfigFilePath: "/path/to/my-project/tsconfig.app.json",
});Component detection rules
The parser discovers components from three declaration forms:
1. Function declarations
// Named PascalCase function that returns JSX
export function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}2. Arrow functions / variable declarations (including wrappers)
// Arrow function
export const Card = ({ title }: CardProps) => <div>{title}</div>;
// React.memo wrapper
export const Layout = React.memo(function Layout({ children }: LayoutProps) {
return <main>{children}</main>;
});
// React.forwardRef wrapper
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ value }, ref) => <input ref={ref} value={value} />,
);
// Export-default memo: export default React.memo(Sidebar)
export default React.memo(Sidebar);3. Class components
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
render() {
if (this.state.hasError) return <ErrorFallback />;
return this.props.children;
}
}
// Always gets complexityClass: "complex"
// detectedHooks: [] (hooks not valid in class components)Manifest query patterns
// All simple components (Satori-renderable)
const simpleComponents = Object.entries(manifest.components)
.filter(([, desc]) => desc.complexityClass === "simple")
.map(([name]) => name);
// All components that use a specific context
const themeConsumers = Object.entries(manifest.components)
.filter(([, desc]) => desc.requiredContexts.includes("ThemeCtx"))
.map(([name]) => name);
// Leaf components (no children in the composition tree)
const leaves = Object.entries(manifest.tree)
.filter(([, node]) => node.children.length === 0)
.map(([name]) => name);
// Root components (no parents)
const roots = Object.entries(manifest.tree)
.filter(([, node]) => node.parents.length === 0)
.map(([name]) => name);
// Components with data fetching
const fetchers = Object.entries(manifest.components)
.filter(([, desc]) => desc.sideEffects.fetches.length > 0)
.map(([name, desc]) => ({ name, fetches: desc.sideEffects.fetches }));
// Components with default-exported named exports
const defaultExports = Object.entries(manifest.components)
.filter(([, desc]) => desc.exportType === "default")
.map(([name]) => name);
// All components with a specific prop
const hasVariantProp = Object.entries(manifest.components)
.filter(([, desc]) => "variant" in desc.props)
.map(([name, desc]) => ({ name, variant: desc.props.variant }));Example payloads
manifest.components.Sidebar (from basic-tree fixture)
{
"filePath": "src/Sidebar.tsx",
"exportType": "default",
"displayName": "Sidebar",
"props": {
"title": { "type": "string", "required": true, "rawType": "string" },
"collapsed": { "type": "boolean", "required": true, "rawType": "boolean" },
"itemCount": { "type": "number", "required": true, "rawType": "number" }
},
"composes": ["NavItem"],
"composedBy": ["Layout"],
"forwardedRef": false,
"memoized": true,
"hocWrappers": [],
"loc": { "start": 10, "end": 40 },
"complexityClass": "complex",
"requiredContexts": [],
"detectedHooks": [],
"sideEffects": {
"fetches": [],
"timers": false,
"subscriptions": [],
"globalListeners": false
}
}Note: complexityClass: "complex" because Sidebar uses transition: "width 0.2s" in its inline style.
manifest.components.DeepConsumer (from deep-context fixture)
{
"filePath": "src/DeepConsumer.tsx",
"exportType": "named",
"displayName": "DeepConsumer",
"props": {},
"composes": [],
"composedBy": ["App"],
"forwardedRef": false,
"memoized": false,
"hocWrappers": [],
"loc": { "start": 1, "end": 25 },
"complexityClass": "simple",
"requiredContexts": ["AuthCtx", "FeatureFlagsCtx", "LocaleCtx", "ThemeCtx", "UserPrefsCtx"],
"detectedHooks": ["useAuth", "useFeatureFlags", "useLocale", "useTheme", "useUserPrefs"],
"sideEffects": {
"fetches": [],
"timers": false,
"subscriptions": [],
"globalListeners": false
}
}manifest.tree (from basic-tree fixture)
{
"App": { "children": ["Layout"], "parents": [] },
"Layout": { "children": ["Sidebar"], "parents": ["App"] },
"Sidebar": { "children": ["NavItem"], "parents": ["Layout"] },
"NavItem": { "children": [], "parents": ["Sidebar"] }
}Internal architecture
| Module | Responsibility |
|--------|----------------|
| types.ts | All TypeScript types (ComponentDescriptor, PropDescriptor, Manifest, ManifestConfig, etc.) |
| analysis.ts | analyzeComplexity, detectHooks, detectRequiredContexts, detectSideEffects, propagateComplexity — all static analysis |
| parser.ts | generateManifest — ts-morph project setup, source file iteration, per-file extraction (functions, arrow functions, class components), composition inverse linking, complexity propagation, tree building |
| index.ts | Public re-exports |
Processing pipeline
ManifestConfig
│
▼
ts-morph Project (tsconfig-aware)
│
▼
Source file filtering (include/exclude globs)
│
├─ for each SourceFile:
│ ├─ processSourceFile()
│ │ ├─ Function declarations → extract props, JSX compositions, wrappers
│ │ ├─ Variable declarations (arrow fns, memo, forwardRef)
│ │ └─ Class components
│ │
│ └─ per component:
│ ├─ analyzeComplexity() — CSS feature scan
│ ├─ detectHooks() — /^use[A-Z]/ pattern
│ ├─ detectRequiredContexts() — useContext() + hook resolution
│ └─ detectSideEffects() — fetch/timer/subscription/global patterns
│
├─ Build composedBy (inverse of composes)
├─ propagateComplexity() — upward BFS from complex leaves
└─ Build tree (filtered to manifest-known components)Used by
@agent-scope/cli— manifest commands (scope manifest generate,scope manifest list,scope manifest show), render commands (usescomplexityClassto choose Satori vs browser pool), CI commands@agent-scope/render—satori.tsandmatrix.tsconsumecomplexityClassandComponentDescriptorfor render strategy decisions
