Internals
The AST package
Parse, walk, extract and generate TypeScript and JSX.
Every deterministic (non-LLM) way Construct reads, walks and generates TypeScript/JSX source, behind one
entry point: packages/ast/index.mjs. Both the CLI (src/) and the UI backend (ui/server) import from it.
// from src/*.mjs: import { extractImports } from './ast/index.mjs';
// from ui/server/src/*.mjs: import { extractImports } from '../../../packages/ast/index.mjs';
Relative imports are deliberate: ui/server already imports ../../../src/*, and typescript /
estree-walker / @typescript-eslint/typescript-estree resolve from the repo-root node_modules. No npm
workspace is used, so the four separate npm install locations are unchanged. package.json here carries
the exports map (., ./parse, ./walk, ./extract, ./ts) for a future move to a real package.
API#
| Area | Function | What it does |
|---|---|---|
| parse | parseToAst(source) |
typescript-estree AST (comments/ranges/loc), JSX first then non-JSX; single-slot memo on the last source |
| parse | parseTsSource(source, fileName?) |
TypeScript-compiler-API SourceFile (parents set, TSX) for type-system questions |
| walk | walkAst(ast, {enter, leave}) |
estree-walker's walk |
| walk | walkForUsage(ast, visit) |
walk skipping non-usage positions (import bindings, re-export specifiers, non-computed member/key names) |
| walk | collectCalls(ast, names:Set) |
real name(...) calls, sorted, {name, index} |
| walk | collectBareIdentifierUsages(ast, names:Set) |
real identifier references, sorted |
| walk | collectControlFlowNodes(ast) |
if/for/while/switch/try nodes; CONTROL_FLOW_TYPES is the set |
| walk | isNonUsagePosition(node, parent, key) |
the predicate behind walkForUsage |
| jsx complexity | collectInlineJsxLogic(ast) |
inline conditional (cond ? <A/> : <B/>, cond && <A/>) and loop-render (.map/.flatMap returning JSX) nodes, sorted — the shape COMPONENT-005 flags |
| jsx complexity | computeJsxComplexity(ast) |
{maxDepth, branchCount} — a component/page's own JSX nesting depth and inline-logic count, the budget COMPONENT-006 caps |
| state shape | collectBagOfFlagsStates(ast, source) |
every "bag of flags" state shape (an interface / object-literal alias / useState-useReducer initial object or inline type / initial-state const / XState context whose fields combine two status flags, or a flag with both error and data), each with the offending fields and a compiling status-union rewrite — the shape STATE-001 flags |
| state shape | classifyStateFields(members) |
the heuristic's boundary on one member list: {flags, error, data} when it is a bag, null when allowed (one flag + data is fine) |
| staleness | collectControllerStateCalls(ast), collectUncleanedSubscriptions(ast), collectParamsReads(ast) |
the three stale-input shapes: a state/ref/effect/memo hook call in a controller (CONTROLLER-003), a useEffect that starts a listener/timer/subscription and returns no cleanup (HOOK-003), a read into params/searchParams in a route (ROUTE-003); each `{name |
| client boundary | readModuleDirective(ast) |
the file's leading directive: 'client' ('use client'), 'server' ('use server') or null; a comment, a later string or ('use client') never counts |
| client boundary | collectModuleEdges(ast, source) |
every value edge to another module (import, export ... from, literal import('...')) with its line; type-only edges are erased and left out — the graph CLIENT-001 walks |
| client boundary | collectSecretEnvReads(ast, source, opts?) |
each read of a non-public process.env.NAME (member, ?., ['NAME'], destructuring); NEXT_PUBLIC_* and NODE_ENV are public — the reads CLIENT-001 flags |
| backend summary | collectBackendFacts(ast, source), collectEffects(ast, facts), collectEnvReads(ast, source), collectHttpRoutes(fnNode) |
the per-file facts of construct summarize --backend: declarations per scope, imports and exports, express receivers with their ordered route / use / hand-over events, fs / child_process / network / timer effects bound to the real module, process.env names, and url === '/x' routes of a plain http handler; the cross-file walk is packages/core/backend-routes.mjs |
| extract | extractImports(source) |
static + dynamic import specifiers, source order |
| extract | extractExports(source) |
[{name, index}] for every export form |
| extract | extractJsdoc(source, index) |
the JSDoc block for the export at index (decorator-aware), or null |
| extract | staticImportEntries(ast) |
[{value, index}] for top-level import declarations only |
| extract | lineOf(source, index) |
1-based line of a character offset |
| ts | ts |
the typescript module (use ts.factory to build nodes) |
| ts | findNode(root, pred) / findAllNodes(root, pred) |
depth-first search of a ts.Node tree |
| ts | printNode(node) |
print a ts.factory node as source (LF newlines) |
packages/core/parser.mjs re-exports parseToAst, extractImports/Exports/Jsdoc and lineOf so older imports keep
working; new code should import from packages/ast.
Runnable example#
import { parseToAst, extractImports, collectCalls, lineOf } from './packages/ast/index.mjs';
const source = `import { a } from './a';\nexport function Page() { return fetch('/x'); }\n// fetch('/ignored')`;
console.log(extractImports(source)); // [ './a' ]
const [hit] = collectCalls(parseToAst(source), new Set(['fetch']));
console.log(hit.name, 'on line', lineOf(source, hit.index)); // fetch on line 2
(A comment or string containing fetch( is never reported — only real calls.)
JSX edit/analysis family (typescript-estree)#
The Pages Editor and the visual composer used to parse/edit JSX with Babel (a third parser stack). Those
operations now live here, on typescript-estree, and ui/server/src/pagesEditor.mjs keeps only the glue
(HTTP error wording/statuses, the hash guard, the enforcement gate, path scoping, cross-file import lookup).
@babel/* is gone from ui/server.
| Module | Functions |
|---|---|
jsxParse.mjs |
parseJsx(source), jsxParseError(source), checkJsxReplacement(snippet) |
jsxTree.mjs |
parseJsxTree(source) -> {roots, byId, ast} (ids n0.. in document order), jsxAttributes, jsxNameToString, findParentRecord |
jsxEdit.mjs |
offset-exact text edits: setAttributeText, setSpreadText, removeAttributeText, removeNodeText, swapNodesText, addChildText, spliceNode, renderAttrValue |
jsxScope.mjs |
collectComponentScopeNames(ast), findImportOfName(ast, name), declaredPropNames(childSource, tag, isDefault), findTypeMembers(source, typeName) |
import { parseJsxTree, setAttributeText, jsxParseError } from './packages/ast/index.mjs';
const src = '<Card title="a" />';
const { byId } = parseJsxTree(src);
const next = setAttributeText(src, byId.get('n0'), 'title', 'string', 'b'); // '<Card title="b" />'
console.log(next, jsxParseError(next)); // ... null
Every edit is a splice of the original text (bytes outside the edited range never change); callers re-parse
the result with jsxParseError. Parity with the former Babel implementation is proven by
ui/server/src/pagesEditor.golden.test.mjs (golden outputs captured from Babel before the migration).
Known, deliberate differences from Babel: (1) the text of parser error details differs (the operation
still fails in the same cases); (2) TypeScript's parser rejects a bare >/} in JSX text, so parseJsx
blanks that one character (same length) in the copy it parses and retries -- offsets are unchanged;
(3) <a b={} /> is re-rejected explicitly (TS treats it as a checker error); (4) Babel's semantic early
errors (const a; const a;, top-level return) and its acceptance of <a.b-c /> are not replicated.