mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
Feature: Feature and preset reference — E2E tests + implementation (#1248)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d2c43db550
commit
a4d8e2a255
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Feature reference extraction.
|
||||
*
|
||||
* Discovers features from packages/core/src/dom/store/features/ and extracts
|
||||
* state/action definitions from their state interfaces in media/state.ts.
|
||||
*
|
||||
* Uses the TypeScript checker API (not TAE) for interface extraction because
|
||||
* state interfaces use method signatures (play(): void) which TAE doesn't
|
||||
* handle — it only handles property-with-function-type syntax.
|
||||
*
|
||||
* Convention:
|
||||
* - Feature files: *.ts in the features directory (excluding index, presets, feature.parts)
|
||||
* - Feature exports: const matching *Feature (singular, not *Features)
|
||||
* - State type: explicit return type annotation on the state() arrow function
|
||||
* - State interfaces: exported from packages/core/src/core/media/state.ts
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as ts from 'typescript';
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import type { FeatureActionDef, FeatureReference, FeatureResult, FeatureStateDef } from './pipeline.js';
|
||||
|
||||
const SKIP_FILES = new Set(['index.ts', 'presets.ts', 'feature.parts.ts']);
|
||||
|
||||
interface FeatureSource {
|
||||
filePath: string;
|
||||
name: string;
|
||||
stateTypeName: string;
|
||||
}
|
||||
|
||||
// ─── Discovery ────────────────────────────────────────────────────
|
||||
|
||||
function discoverFeatureSources(featuresDir: string): FeatureSource[] {
|
||||
const sources: FeatureSource[] = [];
|
||||
const files = fs.readdirSync(featuresDir).filter((f) => f.endsWith('.ts') && !SKIP_FILES.has(f));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(featuresDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
||||
|
||||
ts.forEachChild(sourceFile, (node) => {
|
||||
if (!ts.isVariableStatement(node)) return;
|
||||
if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return;
|
||||
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(decl.name)) continue;
|
||||
const varName = decl.name.text;
|
||||
if (!varName.endsWith('Feature') || varName.endsWith('Features')) continue;
|
||||
|
||||
if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue;
|
||||
const arg = decl.initializer.arguments[0];
|
||||
if (!arg || !ts.isObjectLiteralExpression(arg)) continue;
|
||||
|
||||
let name: string | undefined;
|
||||
let stateTypeName: string | undefined;
|
||||
|
||||
for (const prop of arg.properties) {
|
||||
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue;
|
||||
|
||||
if (prop.name.text === 'name' && ts.isStringLiteral(prop.initializer)) {
|
||||
name = prop.initializer.text;
|
||||
}
|
||||
|
||||
if (prop.name.text === 'state') {
|
||||
const fn = prop.initializer;
|
||||
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.type && ts.isTypeReferenceNode(fn.type)) {
|
||||
stateTypeName = fn.type.typeName.getText(sourceFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (name && stateTypeName) {
|
||||
sources.push({ filePath, name, stateTypeName });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
// ─── Type Formatting ──────────────────────────────────────────────
|
||||
|
||||
function formatCheckerType(type: ts.Type, checker: ts.TypeChecker): string {
|
||||
if (type.isUnion()) {
|
||||
// TypeScript internally represents `boolean` as `false | true`
|
||||
const isBooleanUnion =
|
||||
type.types.length === 2 && type.types.every((t) => !!(t.flags & ts.TypeFlags.BooleanLiteral));
|
||||
if (isBooleanUnion) return 'boolean';
|
||||
|
||||
return type.types.map((t) => formatCheckerType(t, checker)).join(' | ');
|
||||
}
|
||||
if (type.isStringLiteral()) {
|
||||
return `'${type.value}'`;
|
||||
}
|
||||
return checker.typeToString(type);
|
||||
}
|
||||
|
||||
// ─── JSDoc Extraction ─────────────────────────────────────────────
|
||||
|
||||
function getJSDocDescription(node: ts.Node): string | undefined {
|
||||
const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc;
|
||||
if (!jsDocNodes || jsDocNodes.length === 0) return undefined;
|
||||
|
||||
const doc = jsDocNodes[0]!;
|
||||
if (typeof doc.comment === 'string') return doc.comment;
|
||||
if (!doc.comment) return undefined;
|
||||
|
||||
// NodeArray<JSDocComment> — concatenate text parts
|
||||
const parts: string[] = [];
|
||||
for (const part of doc.comment) {
|
||||
if (typeof part === 'string') {
|
||||
parts.push(part);
|
||||
} else if ('text' in part) {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
return parts.join('') || undefined;
|
||||
}
|
||||
|
||||
// ─── Interface Extraction ─────────────────────────────────────────
|
||||
|
||||
function extractInterfaceMembers(
|
||||
interfaceDecl: ts.InterfaceDeclaration,
|
||||
checker: ts.TypeChecker,
|
||||
sourceFile: ts.SourceFile
|
||||
): { state: Record<string, FeatureStateDef>; actions: Record<string, FeatureActionDef> } {
|
||||
const state: Record<string, FeatureStateDef> = {};
|
||||
const actions: Record<string, FeatureActionDef> = {};
|
||||
|
||||
for (const member of interfaceDecl.members) {
|
||||
const name = member.name?.getText(sourceFile);
|
||||
if (!name) continue;
|
||||
|
||||
const description = getJSDocDescription(member);
|
||||
|
||||
if (ts.isMethodSignature(member)) {
|
||||
const params = member.parameters
|
||||
.map((p) => {
|
||||
const pName = p.name.getText(sourceFile);
|
||||
const pType = p.type ? formatCheckerType(checker.getTypeFromTypeNode(p.type), checker) : 'unknown';
|
||||
return `${pName}: ${pType}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
let returnType = 'void';
|
||||
if (member.type) {
|
||||
returnType = formatCheckerType(checker.getTypeFromTypeNode(member.type), checker);
|
||||
}
|
||||
|
||||
const def: FeatureActionDef = { type: `(${params}) => ${returnType}` };
|
||||
if (description) def.description = description;
|
||||
actions[name] = def;
|
||||
} else if (ts.isPropertySignature(member) && member.type) {
|
||||
const memberType = checker.getTypeFromTypeNode(member.type);
|
||||
const typeStr = formatCheckerType(memberType, checker);
|
||||
const def: FeatureStateDef = { type: typeStr };
|
||||
if (description) def.description = description;
|
||||
state[name] = def;
|
||||
}
|
||||
}
|
||||
|
||||
return { state, actions };
|
||||
}
|
||||
|
||||
// ─── Pipeline ─────────────────────────────────────────────────────
|
||||
|
||||
export function generateFeatureReferences(monorepoRoot: string): FeatureResult[] {
|
||||
const featuresDir = path.join(monorepoRoot, 'packages/core/src/dom/store/features');
|
||||
const stateFilePath = path.join(monorepoRoot, 'packages/core/src/core/media/state.ts');
|
||||
|
||||
if (!fs.existsSync(featuresDir) || !fs.existsSync(stateFilePath)) return [];
|
||||
|
||||
const sources = discoverFeatureSources(featuresDir);
|
||||
if (sources.length === 0) return [];
|
||||
|
||||
// Create a TS program with the state file for the checker
|
||||
const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json');
|
||||
const config = tae.loadConfig(tsconfigPath);
|
||||
config.options.rootDir = monorepoRoot;
|
||||
const program = ts.createProgram([stateFilePath], config.options);
|
||||
const checker = program.getTypeChecker();
|
||||
const stateSourceFile = program.getSourceFile(stateFilePath);
|
||||
if (!stateSourceFile) return [];
|
||||
|
||||
// Build a map of interface name → declaration
|
||||
const interfaces = new Map<string, ts.InterfaceDeclaration>();
|
||||
ts.forEachChild(stateSourceFile, (node) => {
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
interfaces.set(node.name.text, node);
|
||||
}
|
||||
});
|
||||
|
||||
const results: FeatureResult[] = [];
|
||||
for (const source of sources) {
|
||||
const interfaceDecl = interfaces.get(source.stateTypeName);
|
||||
if (!interfaceDecl) continue;
|
||||
|
||||
const description = getJSDocDescription(interfaceDecl);
|
||||
const { state, actions } = extractInterfaceMembers(interfaceDecl, checker, stateSourceFile);
|
||||
|
||||
const ref: FeatureReference = {
|
||||
name: source.name,
|
||||
slug: source.name,
|
||||
state,
|
||||
actions,
|
||||
};
|
||||
|
||||
if (description) ref.description = description;
|
||||
|
||||
results.push({ name: source.name, slug: source.name, reference: ref });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user