Line

You are reading the unreleased docs (next). See latest (v0.9)

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.