mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-22 06:59:03 +00:00
* refactor(ui): remove display-only memo and focus bookkeeping * test(ui): warm lazy preview before teardown assertion * test(ui): await lazy skill menu navigation * refactor(ui): ratchet Solid flush and ownership escapes
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
// On-demand syntax census for the remaining Solid migration work.
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import * as ts from "typescript/ast";
|
|
import { createNativeTypeScriptParser } from "./lib/native-typescript.mts";
|
|
import { loadRatchetSources } from "./lib/shrink-ratchet.mts";
|
|
|
|
const ROOTS = ["ui/src", "extensions/workboard/browser", "extensions/x/src"];
|
|
const CODE = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
|
|
const MAX_BUFFER = 256 * 1024 * 1024;
|
|
|
|
function unwrap(input: ts.Node): ts.Node {
|
|
let node = input;
|
|
while (
|
|
ts.isParenthesizedExpression(node) ||
|
|
ts.isAsExpression(node) ||
|
|
ts.isTypeAssertion(node) ||
|
|
ts.isSatisfiesExpression(node) ||
|
|
ts.isNonNullExpression(node)
|
|
) {
|
|
node = node.expression;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function isLitModule(node: ts.Node | undefined) {
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
const literal = unwrap(node);
|
|
return (
|
|
(ts.isStringLiteral(literal) || ts.isNoSubstitutionTemplateLiteral(literal)) &&
|
|
/^(?:lit(?:-html|-element)?|@lit(?:-labs)?\/[^/]+)(?:\/|$)/u.test(literal.text)
|
|
);
|
|
}
|
|
|
|
export function readInventorySources(
|
|
root: string,
|
|
{ ref, staged = false, roots = ROOTS }: { ref?: string; staged?: boolean; roots?: string[] } = {},
|
|
) {
|
|
const args = ref
|
|
? ["ls-tree", "-r", "--name-only", "-z", ref, "--", ...roots]
|
|
: [
|
|
"ls-files",
|
|
"-z",
|
|
...(staged ? [] : ["--cached", "--others", "--exclude-standard"]),
|
|
"--",
|
|
...roots,
|
|
];
|
|
const paths = [
|
|
...new Set(
|
|
execFileSync("git", args, { cwd: root, maxBuffer: MAX_BUFFER })
|
|
.toString("utf8")
|
|
.split("\0")
|
|
.filter((file) => CODE.test(file)),
|
|
),
|
|
].toSorted();
|
|
if (ref || staged) {
|
|
return loadRatchetSources(root, paths, ref);
|
|
}
|
|
return new Map(
|
|
paths
|
|
.filter((file) => fs.existsSync(path.join(root, file)))
|
|
.map((file) => [file, fs.readFileSync(path.join(root, file), "utf8")]),
|
|
);
|
|
}
|
|
|
|
function emptyMetrics() {
|
|
return {
|
|
litImports: 0,
|
|
htmlTemplates: 0,
|
|
svgTemplates: 0,
|
|
cssTemplates: 0,
|
|
waTags: 0,
|
|
stateDecorators: 0,
|
|
propertyDecorators: 0,
|
|
tasks: 0,
|
|
reactiveControllers: 0,
|
|
customElementDefines: 0,
|
|
consume: 0,
|
|
directives: 0,
|
|
todoSolid2: 0,
|
|
};
|
|
}
|
|
export type MigrationMetrics = ReturnType<typeof emptyMetrics>;
|
|
|
|
function nameOf(input: ts.Node): string | undefined {
|
|
const node = unwrap(input);
|
|
if (ts.isComputedPropertyName(node)) {
|
|
return nameOf(node.expression);
|
|
}
|
|
if (
|
|
ts.isIdentifier(node) ||
|
|
ts.isStringLiteral(node) ||
|
|
ts.isNoSubstitutionTemplateLiteral(node)
|
|
) {
|
|
return node.text;
|
|
}
|
|
if (ts.isPropertyAccessExpression(node)) {
|
|
return node.name.text;
|
|
}
|
|
if (ts.isQualifiedName(node)) {
|
|
return node.right.text;
|
|
}
|
|
if (ts.isElementAccessExpression(node) && node.argumentExpression) {
|
|
return nameOf(node.argumentExpression);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function isLitLoad(input: ts.Node): boolean {
|
|
const node = unwrap(input);
|
|
if (ts.isAwaitExpression(node)) {
|
|
return isLitLoad(node.expression);
|
|
}
|
|
return (
|
|
ts.isCallExpression(node) &&
|
|
(unwrap(node.expression).kind === ts.SyntaxKind.ImportKeyword ||
|
|
nameOf(node.expression) === "require") &&
|
|
isLitModule(node.arguments[0])
|
|
);
|
|
}
|
|
|
|
type LitReference = { kind: "namespace" } | { kind: "member"; name: string };
|
|
type LitBinding = {
|
|
imported?: LitReference;
|
|
initializer?: ts.Expression;
|
|
property?: string;
|
|
};
|
|
|
|
// Sibling views may use the same local name for different Lit exports.
|
|
function createLitNameResolver(tree: ts.SourceFile) {
|
|
const scopes = new WeakMap<ts.Node, Map<string, LitBinding>>();
|
|
const scopeFor = (node: ts.Node, functionOnly = false): ts.Node => {
|
|
for (let scope: ts.Node | undefined = node.parent; scope; scope = scope.parent) {
|
|
if (
|
|
ts.isSourceFile(scope) ||
|
|
ts.isFunctionLikeDeclaration(scope) ||
|
|
ts.isClassStaticBlockDeclaration(scope)
|
|
) {
|
|
return scope;
|
|
}
|
|
if (
|
|
!functionOnly &&
|
|
(ts.isBlock(scope) ||
|
|
ts.isModuleBlock(scope) ||
|
|
ts.isCaseBlock(scope) ||
|
|
ts.isCatchClause(scope) ||
|
|
ts.isForStatement(scope) ||
|
|
ts.isForInStatement(scope) ||
|
|
ts.isForOfStatement(scope))
|
|
) {
|
|
return scope;
|
|
}
|
|
}
|
|
return tree;
|
|
};
|
|
const bind = (scope: ts.Node, name: string, binding: LitBinding) => {
|
|
let entries = scopes.get(scope);
|
|
if (!entries) {
|
|
entries = new Map();
|
|
scopes.set(scope, entries);
|
|
}
|
|
entries.set(name, binding);
|
|
};
|
|
const bindPattern = (scope: ts.Node, name: ts.BindingName, binding: LitBinding): void => {
|
|
if (ts.isIdentifier(name)) {
|
|
bind(scope, name.text, binding);
|
|
return;
|
|
}
|
|
for (const element of name.elements) {
|
|
if (!ts.isBindingElement(element) || !element.name) {
|
|
continue;
|
|
}
|
|
const property =
|
|
ts.isObjectBindingPattern(name) && !element.dotDotDotToken
|
|
? nameOf(element.propertyName ?? element.name)
|
|
: undefined;
|
|
bindPattern(
|
|
scope,
|
|
element.name,
|
|
ts.isObjectBindingPattern(name) ? { ...binding, property } : {},
|
|
);
|
|
}
|
|
};
|
|
const collect = (node: ts.Node): void => {
|
|
if (ts.isImportDeclaration(node)) {
|
|
const lit = isLitModule(node.moduleSpecifier);
|
|
const clause = node.importClause;
|
|
if (clause?.name) {
|
|
bind(scopeFor(node), clause.name.text, {});
|
|
}
|
|
const bindings = clause?.namedBindings;
|
|
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
bind(scopeFor(node), bindings.name.text, lit ? { imported: { kind: "namespace" } } : {});
|
|
}
|
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
for (const specifier of bindings.elements) {
|
|
bind(
|
|
scopeFor(node),
|
|
specifier.name.text,
|
|
lit
|
|
? {
|
|
imported: {
|
|
kind: "member",
|
|
name: (specifier.propertyName ?? specifier.name).text,
|
|
},
|
|
}
|
|
: {},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (ts.isImportEqualsDeclaration(node)) {
|
|
bind(
|
|
scopeFor(node),
|
|
node.name.text,
|
|
ts.isExternalModuleReference(node.moduleReference) &&
|
|
isLitModule(node.moduleReference.expression)
|
|
? { imported: { kind: "namespace" } }
|
|
: {},
|
|
);
|
|
}
|
|
if (ts.isVariableDeclaration(node)) {
|
|
const isVar =
|
|
ts.isVariableDeclarationList(node.parent) &&
|
|
!(node.parent.flags & ts.NodeFlags.BlockScoped);
|
|
bindPattern(scopeFor(node, isVar), node.name, { initializer: node.initializer });
|
|
}
|
|
if (ts.isFunctionLikeDeclaration(node)) {
|
|
for (const parameter of node.parameters) {
|
|
bindPattern(node, parameter.name, { initializer: parameter.initializer });
|
|
}
|
|
}
|
|
if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
|
|
bind(scopeFor(node), node.name.text, {});
|
|
}
|
|
if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) {
|
|
bind(node, node.name.text, {});
|
|
}
|
|
node.forEachChild(collect);
|
|
};
|
|
collect(tree);
|
|
const lookup = (node: ts.Identifier): LitBinding | undefined => {
|
|
for (let scope: ts.Node | undefined = node.parent; scope; scope = scope.parent) {
|
|
const binding = scopes.get(scope)?.get(node.text);
|
|
if (binding) {
|
|
return binding;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
const resolve = (input: ts.Node, seen = new Set<LitBinding>()): LitReference | undefined => {
|
|
const node = unwrap(input);
|
|
if (ts.isAwaitExpression(node)) {
|
|
return resolve(node.expression, seen);
|
|
}
|
|
if (isLitLoad(node)) {
|
|
return { kind: "namespace" };
|
|
}
|
|
if (ts.isIdentifier(node)) {
|
|
const binding = lookup(node);
|
|
if (!binding || seen.has(binding)) {
|
|
return undefined;
|
|
}
|
|
if (binding.imported) {
|
|
return binding.imported;
|
|
}
|
|
if (!binding.initializer) {
|
|
return undefined;
|
|
}
|
|
seen.add(binding);
|
|
const reference = resolve(binding.initializer, seen);
|
|
return binding.property
|
|
? reference?.kind === "namespace"
|
|
? { kind: "member", name: binding.property }
|
|
: undefined
|
|
: reference;
|
|
}
|
|
if (
|
|
ts.isPropertyAccessExpression(node) &&
|
|
resolve(node.expression, seen)?.kind === "namespace"
|
|
) {
|
|
return { kind: "member", name: node.name.text };
|
|
}
|
|
if (
|
|
ts.isElementAccessExpression(node) &&
|
|
node.argumentExpression &&
|
|
resolve(node.expression, seen)?.kind === "namespace"
|
|
) {
|
|
const argument = unwrap(node.argumentExpression);
|
|
if (ts.isStringLiteral(argument) || ts.isNoSubstitutionTemplateLiteral(argument)) {
|
|
return { kind: "member", name: argument.text };
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
return (node: ts.Node) => {
|
|
const reference = resolve(node);
|
|
return reference?.kind === "member" ? reference.name : nameOf(node);
|
|
};
|
|
}
|
|
|
|
export function countMigrationSources(root: string, sources: ReadonlyMap<string, string>) {
|
|
using parser = createNativeTypeScriptParser({ cwd: root });
|
|
const result = new Map<string, MigrationMetrics>();
|
|
const files = [...sources].map(([fileName, text]) => ({ fileName, text }));
|
|
const trees = parser.parseSourceFiles(files);
|
|
for (const tree of trees) {
|
|
const fileName = path.relative(root, tree.fileName).split(path.sep).join("/");
|
|
const text = tree.text;
|
|
const diagnostic = parser.getSyntacticDiagnostics(tree.fileName)[0];
|
|
if (diagnostic) {
|
|
throw new Error(
|
|
`${fileName}:${tree.getLineAndCharacterOfPosition(diagnostic.pos).line + 1}: ${diagnostic.text}`,
|
|
);
|
|
}
|
|
const metrics = emptyMetrics();
|
|
const canonicalName = createLitNameResolver(tree);
|
|
const visit = (node: ts.Node): void => {
|
|
// Count module references, including side-effect imports, exports, import types,
|
|
// and dynamic imports. Comments and ordinary strings never become imports.
|
|
if (
|
|
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
isLitModule(node.moduleSpecifier)
|
|
) {
|
|
metrics.litImports++;
|
|
}
|
|
if (
|
|
ts.isImportTypeNode(node) &&
|
|
ts.isLiteralTypeNode(node.argument) &&
|
|
isLitModule(node.argument.literal)
|
|
) {
|
|
metrics.litImports++;
|
|
}
|
|
if (
|
|
ts.isImportEqualsDeclaration(node) &&
|
|
ts.isExternalModuleReference(node.moduleReference) &&
|
|
isLitModule(node.moduleReference.expression)
|
|
) {
|
|
metrics.litImports++;
|
|
}
|
|
if (ts.isCallExpression(node)) {
|
|
const expression = unwrap(node.expression);
|
|
if (isLitLoad(node)) {
|
|
metrics.litImports++;
|
|
}
|
|
if (
|
|
ts.isPropertyAccessExpression(expression) &&
|
|
nameOf(expression.expression) === "customElements" &&
|
|
expression.name.text === "define"
|
|
) {
|
|
metrics.customElementDefines++;
|
|
}
|
|
}
|
|
if (ts.isTaggedTemplateExpression(node)) {
|
|
const name = canonicalName(node.tag);
|
|
if (name === "html") {
|
|
metrics.htmlTemplates++;
|
|
}
|
|
if (name === "svg") {
|
|
metrics.svgTemplates++;
|
|
}
|
|
if (name === "css") {
|
|
metrics.cssTemplates++;
|
|
}
|
|
}
|
|
if (
|
|
ts.isStringLiteral(node) ||
|
|
ts.isNoSubstitutionTemplateLiteral(node) ||
|
|
ts.isTemplateHead(node) ||
|
|
ts.isTemplateMiddle(node) ||
|
|
ts.isTemplateTail(node)
|
|
) {
|
|
metrics.waTags += (node.text.match(/<wa-[a-z0-9-]+(?=[\s/>])/giu) ?? []).length;
|
|
}
|
|
if (
|
|
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
|
|
node.tagName.getText(tree).startsWith("wa-")
|
|
) {
|
|
metrics.waTags++;
|
|
}
|
|
if (ts.isDecorator(node)) {
|
|
const expression = unwrap(node.expression);
|
|
const name = canonicalName(
|
|
ts.isCallExpression(expression) ? expression.expression : expression,
|
|
);
|
|
if (name === "state") {
|
|
metrics.stateDecorators++;
|
|
}
|
|
if (name === "property") {
|
|
metrics.propertyDecorators++;
|
|
}
|
|
if (name === "consume") {
|
|
metrics.consume++;
|
|
}
|
|
if (name === "customElement") {
|
|
metrics.customElementDefines++;
|
|
}
|
|
}
|
|
if (ts.isNewExpression(node) && canonicalName(node.expression) === "Task") {
|
|
metrics.tasks++;
|
|
}
|
|
if (ts.isHeritageClause(node)) {
|
|
for (const type of node.types) {
|
|
const name = canonicalName(
|
|
ts.isTypeReferenceNode(type) ? type.typeName : type.expression,
|
|
);
|
|
if (node.token === ts.SyntaxKind.ImplementsKeyword && name === "ReactiveController") {
|
|
metrics.reactiveControllers++;
|
|
}
|
|
if (
|
|
node.token === ts.SyntaxKind.ExtendsKeyword &&
|
|
(name === "Directive" || name === "AsyncDirective")
|
|
) {
|
|
metrics.directives++;
|
|
}
|
|
}
|
|
}
|
|
node.forEachChild(visit);
|
|
};
|
|
visit(tree);
|
|
metrics.todoSolid2 = (text.match(/TODO\(solid2\)/gu) ?? []).length;
|
|
result.set(fileName, metrics);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function isTest(file: string) {
|
|
return /(?:\.(?:test|spec|fixture|test-support|test-helpers|test-harness)\.|-test-(?:support|harness)\.)|(?:^|\/)(?:__tests__|test-helpers|fixtures|e2e)\//u.test(
|
|
file,
|
|
);
|
|
}
|
|
function bucket(file: string) {
|
|
const parts = file.split("/");
|
|
if (parts[0] === "extensions") {
|
|
return parts.slice(0, 2).join("/");
|
|
}
|
|
if ((parts[2] === "pages" || parts[2] === "components") && parts.length > 4) {
|
|
return parts.slice(2, 4).join("/");
|
|
}
|
|
return parts.length > 3 ? parts.slice(2, 3).join("/") : "(root)";
|
|
}
|
|
function emptyRow() {
|
|
return {
|
|
prodFiles: 0,
|
|
prodLines: 0,
|
|
testFiles: 0,
|
|
litImportFiles: 0,
|
|
...emptyMetrics(),
|
|
testLitFiles: 0,
|
|
testUpdateCompleteFiles: 0,
|
|
testWaSelectorFiles: 0,
|
|
};
|
|
}
|
|
|
|
export function main(root = process.cwd(), argv = process.argv.slice(2)) {
|
|
let ref: string | undefined;
|
|
let asJson = false;
|
|
for (let index = 0; index < argv.length; index++) {
|
|
if (argv[index] === "--json") {
|
|
asJson = true;
|
|
} else if (argv[index] === "--ref" && argv[index + 1]) {
|
|
ref = argv[++index];
|
|
} else {
|
|
throw new Error("Usage: pnpm ui:solid:inventory [--json] [--ref <commit>]");
|
|
}
|
|
}
|
|
const revision = execFileSync(
|
|
"git",
|
|
["rev-parse", "--verify", "--end-of-options", `${ref ?? "HEAD"}^{commit}`],
|
|
{
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
},
|
|
).trim();
|
|
const sources = readInventorySources(root, ref ? { ref: revision } : {});
|
|
const counts = countMigrationSources(root, sources);
|
|
const rows = new Map<string, ReturnType<typeof emptyRow>>();
|
|
const totals = emptyRow();
|
|
for (const [file, metrics] of counts) {
|
|
const key = bucket(file);
|
|
let row = rows.get(key);
|
|
if (!row) {
|
|
row = emptyRow();
|
|
rows.set(key, row);
|
|
}
|
|
const text = sources.get(file)!;
|
|
for (const target of [row, totals]) {
|
|
if (isTest(file)) {
|
|
target.testFiles++;
|
|
target.testLitFiles += Number(metrics.litImports > 0);
|
|
target.testUpdateCompleteFiles += Number(/\bupdateComplete\b/u.test(text));
|
|
target.testWaSelectorFiles += Number(/\bwa-[a-z-]+/u.test(text));
|
|
} else {
|
|
target.prodFiles++;
|
|
target.prodLines += text.split("\n").length - Number(text.endsWith("\n"));
|
|
target.litImportFiles += Number(metrics.litImports > 0);
|
|
for (const metric of Object.keys(metrics) as (keyof MigrationMetrics)[]) {
|
|
target[metric] += metrics[metric];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const buckets = Object.fromEntries(
|
|
[...rows].toSorted(
|
|
([a, left], [b, right]) => right.prodLines - left.prodLines || a.localeCompare(b),
|
|
),
|
|
);
|
|
const head = revision.slice(0, 12);
|
|
if (asJson) {
|
|
console.log(JSON.stringify({ head, roots: ROOTS, totals, buckets }, null, 2));
|
|
} else {
|
|
const columns = [
|
|
"prodLines",
|
|
"litImportFiles",
|
|
"htmlTemplates",
|
|
"waTags",
|
|
"stateDecorators",
|
|
"tasks",
|
|
"reactiveControllers",
|
|
"todoSolid2",
|
|
"testFiles",
|
|
"testLitFiles",
|
|
] as const;
|
|
console.log(
|
|
`# Control UI Solid migration inventory @ ${head}\n\n| bucket | ${columns.join(" | ")} |\n|---|${columns.map(() => "---:").join("|")}|`,
|
|
);
|
|
for (const [key, row] of Object.entries(buckets)) {
|
|
console.log(`| ${key} | ${columns.map((column) => row[column]).join(" | ")} |`);
|
|
}
|
|
console.log(`| **total** | ${columns.map((column) => `**${totals[column]}**`).join(" | ")} |`);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
process.exitCode = main();
|
|
}
|