// parseFeatureFile — entry-point for AST pattern detection. Reads a
// `defineFeature.ts` file, walks the `setup(r => { ... })` callback,
// and emits one FeaturePattern per recognised `r.*` call.
//
// **Pipeline position:**
//
// ┌──────────────────┐ parseFeatureFile ┌─────────────────────┐
// │ feature-file.ts │ ──────────────────────► │ ParseResult │
// │ (defineFeature │ │ - featureName │
// │ with r.* calls) │ │ - patterns: FP[] │
// └──────────────────┘ │ - errors: PE[] │
// └─────────────────────┘
//
// **What is NOT extracted:**
// - Imports, helper functions, local consts between the `r.*` calls.
// Those stay in the file buffer and survive every patch unchanged.
// - Top-level code outside `defineFeature(...)`. Designer/AI treat
// such files as "not a feature file".
//
// **Skeleton status (C1.3):** interface fixed, extractors are TODO.
// Per-pattern extractors fill in iteratively (C1.5) — each round adds
// one extractor + a focused test.
import type {
ArrowFunction,
CallExpression,
Expression,
Node,
ObjectLiteralExpression,
ParameterDeclaration,
SourceFile,
} from "ts-morph";
import { Project, SyntaxKind } from "ts-morph";
import {
type ExtractOutput,
extractAiClassify,
extractAiExtract,
extractAiGenerate,
extractAuthClaims,
extractClaimKey,
extractConfig,
extractDefineEvent,
extractDescribe,
extractEntity,
extractEnvSchema,
extractExposesApi,
extractExtendsRegistrar,
extractHook,
extractHttpRoute,
extractJob,
extractMetric,
extractMultiStreamProjection,
extractNav,
extractNotification,
extractOptionalRequires,
extractProjection,
extractQueryHandler,
extractReadsConfig,
extractReferenceData,
extractRelation,
extractRequires,
extractScreen,
extractSecret,
extractStoreTable,
extractStreamHandler,
extractSystemScope,
extractToggleable,
extractTranslations,
extractTreeActions,
extractUiHints,
extractUseExtension,
extractUsesApi,
extractWorkspace,
extractWriteHandler,
findFunctionLiteral,
} from "./extractors";
import type { FeaturePattern, UnknownPattern } from "./patterns";
import { type SourceLocation, sourceLocationFromNode } from "./source-location";
// =============================================================================
// Public API
// =============================================================================
export type ParseError = {
// Which r.* call could not be parsed (only the method name —
// a call without a method name isn't an r.* call and never lands
// here).
readonly methodName: string;
// Where in the file. The Designer can highlight the spot
// ("call not understood here").
readonly source: SourceLocation;
// Free-form description (e.g. "argument 0 is not an object literal,
// cannot read EntityDefinition statically").
readonly reason: string;
};
export type ParseResult = {
// Extracted from `defineFeature("name", ...)`. Undefined when the
// file has no `defineFeature` call (then `patterns` is empty too).
readonly featureName: string | undefined;
// Recognised r.* calls in source order.
readonly patterns: readonly FeaturePattern[];
// Calls whose arguments we could not statically read. The method
// name is known; only the payload was unreachable. Designer renders
// them as "cannot edit", AI patcher leaves them alone. Distinct from
// UnknownPattern (where the method name itself is unknown).
readonly errors: readonly ParseError[];
};
/**
* Parse the given feature file and return all recognised r.* calls
* as a FeaturePattern list.
*
* Does NOT throw on TypeScript errors — the visitor works on the syntax
* tree, not on the type checker. Files with type errors can still be
* parsed structurally (Designer keeps showing them, AI can suggest fixes).
*
* Throws on filesystem / parse errors that ts-morph cannot recover from.
*/
export function parseFeatureFile(filePath: string): ParseResult {
const project = new Project({
// Skip tsconfig file discovery: we only ever load this single file.
// Without these flags ts-morph would resolve the whole tsconfig
// tree, which is expensive on large repos and unnecessary for
// structural analysis.
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
});
const sourceFile = project.addSourceFileAtPath(filePath);
return parseSourceFile(sourceFile);
}
/**
* Same as parseFeatureFile, but for SourceFiles already loaded by the
* caller. Useful for tests + the Designer (which keeps its own Project
* instance and avoids re-IO per parse).
*/
export function parseSourceFile(sourceFile: SourceFile): ParseResult {
const setupCall = findDefineFeatureCall(sourceFile);
if (!setupCall) {
return { featureName: undefined, patterns: [], errors: [] };
}
const featureName = extractFeatureName(setupCall);
const setupCallback = extractSetupCallback(setupCall);
if (!setupCallback) {
return { featureName, patterns: [], errors: [] };
}
const registrarParamName = extractRegistrarParamName(setupCallback);
if (!registrarParamName) {
return { featureName, patterns: [], errors: [] };
}
const patterns: FeaturePattern[] = [];
const errors: ParseError[] = [];
walkSetupCallback(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
walkAiStepCalls(setupCallback.getBody(), registrarParamName, sourceFile, patterns, errors);
patterns.sort((a, b) => a.source.start.line - b.source.start.line);
return { featureName, patterns, errors };
}
// =============================================================================
// Internal — locate defineFeature + setup callback
// =============================================================================
/**
* Find the `defineFeature(name, setup)` call in the source file.
* Convention: each feature file invokes `defineFeature` exactly once
* at top level. If multiple calls exist (unusual outside of test
* helpers) we take the first.
*/
function findDefineFeatureCall(sourceFile: SourceFile): CallExpression | undefined {
for (const stmt of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expr = stmt.getExpression();
if (expr.getText() === "defineFeature") {
return stmt;
}
}
return undefined;
}
/**
* Read the feature name from the first argument of `defineFeature(...)`.
* Returns undefined when the argument is not a string literal (e.g.
* dynamic name from a const). Designer can still load the file but the
* AI generator will never produce such a setup.
*/
function extractFeatureName(call: CallExpression): string | undefined {
const arg = call.getArguments()[0];
if (!arg) return undefined;
const literal = arg.asKind(SyntaxKind.StringLiteral);
if (!literal) return undefined;
return literal.getLiteralValue();
}
/**
* Read the `setup` callback (second argument) from `defineFeature(...)`.
* We only support the arrow-function form here — function expressions
* have not turned up in any sample so far. If a future feature needs
* them, this is the single hook to extend.
*/
function extractSetupCallback(call: CallExpression): ArrowFunction | undefined {
const arg = call.getArguments()[1];
if (!arg) return undefined;
return arg.asKind(SyntaxKind.ArrowFunction);
}
/**
* Read the parameter name of the setup callback's first parameter.
* Idiomatic feature files call it `r`, but `(registrar) => { ... }` or
* `(reg) => { ... }` are equally legal — the visitor must follow the
* author's choice or it would silently miss every call.
*
* Returns undefined when the callback takes no parameter at all (a
* feature that does nothing — empty patterns list, no error).
*/
function extractRegistrarParamName(setup: ArrowFunction): string | undefined {
const param: ParameterDeclaration | undefined = setup.getParameters()[0];
if (!param) return undefined;
return param.getName();
}
// =============================================================================
// Internal — walk + dispatch
// =============================================================================
/**
* Walk the body of the setup callback and route every `.(...)`
* call to the matching extractor. Helper functions / local variables /
* imports are ignored.
*
* We rely on `getDescendantsOfKind` rather than walking only direct
* children: the `r.*` API is always called on the registrar variable,
* which the closure rules keep scoped to the setup callback. Nested
* calls inside a writeHandler closure would not see `r` and therefore
* cannot pose as feature-level patterns — collisions are
* structurally impossible.
*/
function walkSetupCallback(
body: Node,
registrarParamName: string,
sourceFile: SourceFile,
patterns: FeaturePattern[],
errors: ParseError[],
visitedWrapperDecls: Set = new Set(),
): void {
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const methodName = extractRegistrarMethodName(call, registrarParamName);
if (methodName) {
const result = dispatchExtractor(methodName, call, sourceFile);
if (result.kind === "pattern") {
patterns.push(result.pattern);
} else {
errors.push(result.error);
}
continue;
}
// Not a direct `.(...)` call. Check whether it's
// a registrar-wrapper call — a function that receives the registrar as
// a bare argument, e.g. `registerShowPonyScreens(r)`. Such wrappers are
// invisible to a receiver-shape match, so we resolve the callee (same
// file, or the declaring file of a named import — #1008) and recurse
// into its body with its own parameter name for the registrar slot.
const wrapper = resolveRegistrarWrapperCall(call, registrarParamName);
if (!wrapper) continue; // Free function call unrelated to the registrar — ignore.
if (visitedWrapperDecls.has(wrapper.declNode)) continue; // Cycle guard.
visitedWrapperDecls.add(wrapper.declNode);
// wrapper.sourceFile, not the outer `sourceFile` param: a cross-file
// wrapper's body lives in a different file, and sourceLocationFromNode
// uses whichever SourceFile it's given for both the `file` path and
// the line/column table — passing the wrong one silently corrupts
// both for every pattern found inside the wrapper.
walkSetupCallback(
wrapper.body,
wrapper.paramName,
wrapper.sourceFile,
patterns,
errors,
visitedWrapperDecls,
);
}
}
/**
* Resolves a bare call like `registerShowPonyScreens(r)` to the callee's
* body + its own parameter name for the registrar slot. The callee may be
* declared in the same file (function declaration, or a const initialized
* with an arrow/function expression), or imported by name from another
* file — resolved via the module specifier's SourceFile (#1008; only
* works against a real-filesystem Project, e.g. parseFeatureFile's. The
* in-memory Designer Project has no files to resolve against and
* getModuleSpecifierSourceFile() returns undefined there, same as any
* other unresolvable reference).
*/
function resolveRegistrarWrapperCall(
call: CallExpression,
registrarParamName: string,
):
| {
readonly body: Node;
readonly paramName: string;
readonly declNode: Node;
readonly sourceFile: SourceFile;
}
| undefined {
const callee = call.getExpression().asKind(SyntaxKind.Identifier);
if (!callee) return undefined;
const args = call.getArguments();
const argIndex = args.findIndex((arg) => arg.getText() === registrarParamName);
if (argIndex === -1) return undefined; // Registrar not passed to this call.
const callSourceFile = call.getSourceFile();
const name = callee.getText();
const local = resolveWrapperDeclarationInFile(callSourceFile, name, argIndex);
if (local) return { ...local, sourceFile: callSourceFile };
const importDecl = callSourceFile
.getImportDeclarations()
.find((d) =>
d.getNamedImports().some((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name),
);
if (!importDecl) return undefined;
const exportedName =
importDecl
.getNamedImports()
.find((ni) => (ni.getAliasNode()?.getText() ?? ni.getName()) === name)
?.getName() ?? name;
const targetFile = importDecl.getModuleSpecifierSourceFile();
if (!targetFile) return undefined; // Unresolvable: in-memory project, external package, or missing file.
const imported = resolveWrapperDeclarationInFile(targetFile, exportedName, argIndex);
return imported ? { ...imported, sourceFile: targetFile } : undefined;
}
function resolveWrapperDeclarationInFile(
sourceFile: SourceFile,
name: string,
argIndex: number,
): { readonly body: Node; readonly paramName: string; readonly declNode: Node } | undefined {
const fnDecl = sourceFile.getFunction(name);
if (fnDecl) {
const param = fnDecl.getParameters()[argIndex];
const body = fnDecl.getBody();
if (!param || !body) return undefined;
return { body, paramName: param.getName(), declNode: fnDecl };
}
const varDecl = sourceFile.getVariableDeclaration(name);
const init = varDecl?.getInitializer();
const literal = init ? findFunctionLiteral(init) : undefined;
const fnLiteral =
literal?.asKind(SyntaxKind.ArrowFunction) ?? literal?.asKind(SyntaxKind.FunctionExpression);
if (fnLiteral) {
const param = fnLiteral.getParameters()[argIndex];
if (!param) return undefined;
return { body: fnLiteral.getBody(), paramName: param.getName(), declNode: fnLiteral };
}
return undefined;
}
/**
* Returns the method name when the call has the form
* `.(...)`, otherwise undefined.
*
* Reads the property-access expression on the call's left-hand side
* and matches the receiver against the captured parameter name. Any
* other shape (free function call, method call on something else)
* returns undefined and is skipped by the walker.
*/
function extractRegistrarMethodName(
call: CallExpression,
registrarParamName: string,
): string | undefined {
const expr = call.getExpression();
const propAccess = expr.asKind(SyntaxKind.PropertyAccessExpression);
if (!propAccess) return undefined;
const receiver = propAccess.getExpression();
if (receiver.getText() !== registrarParamName) return undefined;
return propAccess.getName();
}
function readObjectPropertyInitializer(
obj: ObjectLiteralExpression,
propertyName: string,
): Expression | undefined {
const prop = obj.getProperty(propertyName);
if (!prop) return undefined;
const assign = prop.asKind(SyntaxKind.PropertyAssignment);
if (assign) return assign.getInitializer();
const shorthand = prop.asKind(SyntaxKind.ShorthandPropertyAssignment);
if (shorthand) return shorthand.getNameNode();
return undefined;
}
function resolveSameFileObjectLiteralArg(node: Node): ObjectLiteralExpression | undefined {
const direct = node.asKind(SyntaxKind.ObjectLiteralExpression);
if (direct) return direct;
const identifier = node.asKind(SyntaxKind.Identifier);
if (!identifier) return undefined;
const varDecl = node.getSourceFile().getVariableDeclaration(identifier.getText());
return varDecl?.getInitializer()?.asKind(SyntaxKind.ObjectLiteralExpression);
}
function resolveStepsArrayRoot(stepsInit: Expression): Node | undefined {
const directArray = stepsInit.asKind(SyntaxKind.ArrayLiteralExpression);
if (directArray) return directArray;
const pipelineCall = stepsInit.asKind(SyntaxKind.CallExpression);
if (pipelineCall?.getExpression().getText() !== "stepsPipeline") {
return undefined;
}
const closureArg = pipelineCall.getArguments()[0];
if (!closureArg) return undefined;
const fn = findFunctionLiteral(closureArg);
if (!fn) return undefined;
const fnBody =
fn.asKind(SyntaxKind.ArrowFunction)?.getBody() ??
fn.asKind(SyntaxKind.FunctionExpression)?.getBody();
const exprBody = fnBody?.asKind(SyntaxKind.ArrayLiteralExpression);
if (exprBody) return exprBody;
if (fnBody?.isKind(SyntaxKind.Block)) {
for (const stmt of fnBody.getStatements()) {
const ret = stmt.asKind(SyntaxKind.ReturnStatement);
const retArray = ret?.getExpression()?.asKind(SyntaxKind.ArrayLiteralExpression);
if (retArray) return retArray;
}
}
return undefined;
}
function collectWorkflowStepArrayRoots(body: Node): Node[] {
const roots: Node[] = [];
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (call.getExpression().getText() !== "defineWorkflow") continue;
const obj = resolveSameFileObjectLiteralArg(call.getArguments()[0] ?? call);
if (!obj) continue;
const stepsInit = readObjectPropertyInitializer(obj, "steps");
if (!stepsInit) continue;
const arrayRoot = resolveStepsArrayRoot(stepsInit);
if (arrayRoot) roots.push(arrayRoot);
}
return roots;
}
function walkAiStepCallsInNode(
node: Node,
sourceFile: SourceFile,
patterns: FeaturePattern[],
errors: ParseError[],
): void {
for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpression().getText();
switch (callee) {
case "aiGenerateStep": {
const result = extractAiGenerate(call, sourceFile);
if (result.kind === "pattern") patterns.push(result.pattern);
else errors.push(result.error);
break;
}
case "aiExtractStep": {
const result = extractAiExtract(call, sourceFile);
if (result.kind === "pattern") patterns.push(result.pattern);
else errors.push(result.error);
break;
}
case "aiClassifyStep": {
const result = extractAiClassify(call, sourceFile);
if (result.kind === "pattern") patterns.push(result.pattern);
else errors.push(result.error);
break;
}
default:
break;
}
}
}
function walkAiStepCalls(
body: Node,
registrarParamName: string,
sourceFile: SourceFile,
patterns: FeaturePattern[],
errors: ParseError[],
visitedWrapperDecls: Set = new Set(),
): void {
for (const arrayRoot of collectWorkflowStepArrayRoots(body)) {
walkAiStepCallsInNode(arrayRoot, sourceFile, patterns, errors);
}
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const wrapper = resolveRegistrarWrapperCall(call, registrarParamName);
if (!wrapper) continue;
if (visitedWrapperDecls.has(wrapper.declNode)) continue;
visitedWrapperDecls.add(wrapper.declNode);
walkAiStepCalls(
wrapper.body,
wrapper.paramName,
wrapper.sourceFile,
patterns,
errors,
visitedWrapperDecls,
);
}
}
// =============================================================================
// Internal — pattern extractors (skeleton, implementation in C1.5)
// =============================================================================
/**
* Route an `.(...)` call to its concrete extractor. New
* r.* APIs → new case + new extractor + new pattern type in patterns.ts.
* The discriminated union forces consumers (Designer, AI patcher) to
* commit to the new kind via compile errors.
*
* Skeleton: every recognised method currently returns an UnknownPattern
* with the right method name. C1.5 replaces the cases with concrete
* extractors one at a time.
*/
function dispatchExtractor(
methodName: string,
call: CallExpression,
sourceFile: SourceFile,
): ExtractOutput {
switch (methodName) {
// Round 1 — simplest static patterns
case "requires":
return extractRequires(call, sourceFile);
case "optionalRequires":
return extractOptionalRequires(call, sourceFile);
case "readsConfig":
return extractReadsConfig(call, sourceFile);
case "systemScope":
return extractSystemScope(call, sourceFile);
case "toggleable":
return extractToggleable(call, sourceFile);
case "describe":
return extractDescribe(call, sourceFile);
case "uiHints":
return extractUiHints(call, sourceFile);
// Round 2 — object-literal-based static patterns
case "entity":
return extractEntity(call, sourceFile);
case "relation":
return extractRelation(call, sourceFile);
case "nav":
return extractNav(call, sourceFile);
case "workspace":
return extractWorkspace(call, sourceFile);
// Round 3 — complex static patterns
case "config":
return extractConfig(call, sourceFile);
case "translations":
return extractTranslations(call, sourceFile);
case "metric":
return extractMetric(call, sourceFile);
case "secret":
return extractSecret(call, sourceFile);
case "claimKey":
return extractClaimKey(call, sourceFile);
case "referenceData":
return extractReferenceData(call, sourceFile);
case "useExtension":
return extractUseExtension(call, sourceFile);
// Round 4 — mixed (header + body) patterns
case "hook":
return extractHook(call, sourceFile);
case "authClaims":
return extractAuthClaims(call, sourceFile);
case "writeHandler":
return extractWriteHandler(call, sourceFile);
case "queryHandler":
return extractQueryHandler(call, sourceFile);
case "streamHandler":
return extractStreamHandler(call, sourceFile);
case "job":
return extractJob(call, sourceFile);
case "httpRoute":
return extractHttpRoute(call, sourceFile);
case "defineEvent":
return extractDefineEvent(call, sourceFile);
case "notification":
return extractNotification(call, sourceFile);
case "projection":
return extractProjection(call, sourceFile);
case "multiStreamProjection":
return extractMultiStreamProjection(call, sourceFile);
case "screen":
return extractScreen(call, sourceFile);
// Round 5 — opaque patterns
case "extendsRegistrar":
return extractExtendsRegistrar(call, sourceFile);
case "usesApi":
return extractUsesApi(call, sourceFile);
case "exposesApi":
return extractExposesApi(call, sourceFile);
case "storeTable":
return extractStoreTable(call, sourceFile);
// Round 6 — Tree-Actions pattern
case "treeActions":
return extractTreeActions(call, sourceFile);
// Round 7 — env-schema contract (opaque, Zod-expression argument)
case "envSchema":
return extractEnvSchema(call, sourceFile);
// Unknown method — UnknownPattern signal so Designer/AI surface it
// as "custom call" without losing the source location.
default:
return {
kind: "pattern",
pattern: makeUnknownPattern(methodName, call, sourceFile),
};
}
}
function makeUnknownPattern(
methodName: string,
call: CallExpression,
sourceFile: SourceFile,
): UnknownPattern {
return {
kind: "unknown",
methodName,
source: sourceLocationFromNode(call, sourceFile),
};
}