Refactor: Enhance field listing script to support runtime sources, typed arrays, and advanced scope handling

This commit is contained in:
2026-04-27 16:00:05 +02:00
parent 2f1cbf8ce8
commit 7b56e540bf

View File

@@ -1,7 +1,9 @@
import path from "node:path"; import path from "node:path";
import ts from "typescript"; import ts from "typescript";
interface AuthorableRoot { type InventoryScope = "authorable" | "runtime" | "all";
interface FieldRoot {
title: string; title: string;
file: string; file: string;
typeName: string; typeName: string;
@@ -14,7 +16,14 @@ interface FieldEntry {
condition: string | null; condition: string | null;
} }
const ROOTS: readonly AuthorableRoot[] = [ interface TraversalOptions {
condition: string | null;
skipProperties: ReadonlySet<string>;
includeIdentityProperties: boolean;
skipPropertiesForThisObject?: ReadonlySet<string>;
}
const AUTHORABLE_ROOTS: readonly FieldRoot[] = [
{ {
title: "Project", title: "Project",
file: "src/document/scene-document.ts", file: "src/document/scene-document.ts",
@@ -121,6 +130,8 @@ const ROOTS: readonly AuthorableRoot[] = [
} }
]; ];
const RUNTIME_SOURCE_DIRECTORIES = ["src/runtime-three"] as const;
const RUNTIME_SOURCE_FILES = ["src/controls/control-surface.ts"] as const;
const IDENTITY_PROPERTIES = new Set(["id", "version"]); const IDENTITY_PROPERTIES = new Set(["id", "version"]);
const DISCRIMINATOR_CANDIDATES = [ const DISCRIMINATOR_CANDIDATES = [
"mode", "mode",
@@ -132,6 +143,17 @@ const DISCRIMINATOR_CANDIDATES = [
"format", "format",
"kind" "kind"
] as const; ] as const;
const TYPED_ARRAY_TYPE_NAMES = new Set([
"Float32Array",
"Float64Array",
"Int8Array",
"Int16Array",
"Int32Array",
"Uint8Array",
"Uint8ClampedArray",
"Uint16Array",
"Uint32Array"
]);
const repoRoot = process.cwd(); const repoRoot = process.cwd();
const configPath = ts.findConfigFile(repoRoot, ts.sys.fileExists, "tsconfig.json"); const configPath = ts.findConfigFile(repoRoot, ts.sys.fileExists, "tsconfig.json");
@@ -157,7 +179,7 @@ const program = ts.createProgram(parsedConfig.fileNames, {
}); });
const checker = program.getTypeChecker(); const checker = program.getTypeChecker();
function getRootType(root: AuthorableRoot): ts.Type { function getRootType(root: FieldRoot): ts.Type {
const sourceFile = program.getSourceFile(path.join(repoRoot, root.file)); const sourceFile = program.getSourceFile(path.join(repoRoot, root.file));
if (sourceFile === undefined) { if (sourceFile === undefined) {
@@ -182,6 +204,79 @@ function getRootType(root: AuthorableRoot): ts.Type {
return checker.getTypeAtLocation(foundName); return checker.getTypeAtLocation(foundName);
} }
function isExportedDeclaration(node: ts.Node): boolean {
return (
ts.canHaveModifiers(node) &&
(ts.getModifiers(node)?.some(
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
) ??
false)
);
}
function formatTypeTitle(typeName: string): string {
return typeName
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.trim();
}
function isRuntimeSourceFile(sourceFile: ts.SourceFile): boolean {
const relativePath = path.relative(repoRoot, sourceFile.fileName);
return (
RUNTIME_SOURCE_FILES.includes(relativePath as (typeof RUNTIME_SOURCE_FILES)[number]) ||
RUNTIME_SOURCE_DIRECTORIES.some((directory) =>
relativePath.startsWith(`${directory}${path.sep}`)
)
);
}
function discoverRuntimeRoots(): FieldRoot[] {
const roots: FieldRoot[] = [];
for (const sourceFile of program.getSourceFiles()) {
if (
sourceFile.isDeclarationFile ||
sourceFile.fileName.includes(`${path.sep}node_modules${path.sep}`) ||
!isRuntimeSourceFile(sourceFile)
) {
continue;
}
const relativePath = path.relative(repoRoot, sourceFile.fileName);
for (const statement of sourceFile.statements) {
if (
!isExportedDeclaration(statement) ||
(!ts.isInterfaceDeclaration(statement) && !ts.isTypeAliasDeclaration(statement)) ||
!statement.name.text.startsWith("Runtime")
) {
continue;
}
const type = checker.getTypeAtLocation(statement.name);
if (isLeafType(type)) {
continue;
}
roots.push({
title: formatTypeTitle(statement.name.text),
file: relativePath,
typeName: statement.name.text,
path: `runtime.${statement.name.text}`
});
}
}
return roots.sort(
(left, right) =>
left.file.localeCompare(right.file) ||
left.typeName.localeCompare(right.typeName)
);
}
function withoutNullish(type: ts.Type): ts.Type { function withoutNullish(type: ts.Type): ts.Type {
if (!type.isUnion()) { if (!type.isUnion()) {
return type; return type;
@@ -228,6 +323,12 @@ function getArrayElementType(type: ts.Type): ts.Type | null {
return checker.getTypeArguments(normalizedType as ts.TypeReference)[0] ?? null; return checker.getTypeArguments(normalizedType as ts.TypeReference)[0] ?? null;
} }
function isTypedArrayType(type: ts.Type): boolean {
const normalizedType = withoutNullish(type);
const typeName = checker.typeToString(normalizedType);
return TYPED_ARRAY_TYPE_NAMES.has(typeName);
}
function getRecordValueType(type: ts.Type): ts.Type | null { function getRecordValueType(type: ts.Type): ts.Type | null {
const normalizedType = withoutNullish(type); const normalizedType = withoutNullish(type);
return checker.getIndexTypeOfType(normalizedType, ts.IndexKind.String) ?? null; return checker.getIndexTypeOfType(normalizedType, ts.IndexKind.String) ?? null;
@@ -279,13 +380,11 @@ function literalUnionLabels(type: ts.Type): string[] {
function shouldSkipProperty( function shouldSkipProperty(
propertyName: string, propertyName: string,
propertyType: ts.Type | null, propertyType: ts.Type | null,
options: { options: TraversalOptions
skipProperties: ReadonlySet<string>;
skipPropertiesForThisObject?: ReadonlySet<string>;
}
): boolean { ): boolean {
if ( if (
IDENTITY_PROPERTIES.has(propertyName) || (!options.includeIdentityProperties &&
IDENTITY_PROPERTIES.has(propertyName)) ||
options.skipProperties.has(propertyName) || options.skipProperties.has(propertyName) ||
options.skipPropertiesForThisObject?.has(propertyName) options.skipPropertiesForThisObject?.has(propertyName)
) { ) {
@@ -296,7 +395,10 @@ function shouldSkipProperty(
return false; return false;
} }
return literalUnionLabels(propertyType).length === 1; return (
!options.includeIdentityProperties &&
literalUnionLabels(propertyType).length === 1
);
} }
function chooseDiscriminator(types: readonly ts.Type[]): string | null { function chooseDiscriminator(types: readonly ts.Type[]): string | null {
@@ -355,10 +457,7 @@ function collectFields(
type: ts.Type, type: ts.Type,
currentPath: string, currentPath: string,
entries: FieldEntry[], entries: FieldEntry[],
options: { options: TraversalOptions
condition: string | null;
skipProperties: ReadonlySet<string>;
}
): void { ): void {
const normalizedType = withoutNullish(type); const normalizedType = withoutNullish(type);
@@ -377,6 +476,14 @@ function collectFields(
return; return;
} }
if (isTypedArrayType(normalizedType)) {
entries.push({
path: `${currentPath}[]`,
condition: options.condition
});
return;
}
const recordValueType = getRecordValueType(normalizedType); const recordValueType = getRecordValueType(normalizedType);
if (recordValueType !== null) { if (recordValueType !== null) {
@@ -396,10 +503,7 @@ function collectUnionFields(
types: readonly ts.Type[], types: readonly ts.Type[],
currentPath: string, currentPath: string,
entries: FieldEntry[], entries: FieldEntry[],
options: { options: TraversalOptions
condition: string | null;
skipProperties: ReadonlySet<string>;
}
): void { ): void {
const objectTypes = types.filter((type) => !isLeafType(type)); const objectTypes = types.filter((type) => !isLeafType(type));
@@ -415,9 +519,11 @@ function collectUnionFields(
if ( if (
discriminator !== null && discriminator !== null &&
discriminator !== "kind" && !shouldSkipProperty(
!IDENTITY_PROPERTIES.has(discriminator) && discriminator,
!options.skipProperties.has(discriminator) getPropertyType(objectTypes[0]!, discriminator),
options
)
) { ) {
entries.push({ entries.push({
path: `${currentPath}.${discriminator}`, path: `${currentPath}.${discriminator}`,
@@ -487,7 +593,8 @@ function collectUnionFields(
if (groupTypes.length > 1) { if (groupTypes.length > 1) {
collectUnionFields(groupTypes, currentPath, entries, { collectUnionFields(groupTypes, currentPath, entries, {
condition, condition,
skipProperties: skippedForGroup skipProperties: skippedForGroup,
includeIdentityProperties: options.includeIdentityProperties
}); });
continue; continue;
} }
@@ -495,6 +602,7 @@ function collectUnionFields(
collectObjectFields(groupTypes[0]!, currentPath, entries, { collectObjectFields(groupTypes[0]!, currentPath, entries, {
condition, condition,
skipProperties: options.skipProperties, skipProperties: options.skipProperties,
includeIdentityProperties: options.includeIdentityProperties,
skipPropertiesForThisObject: skippedForGroup skipPropertiesForThisObject: skippedForGroup
}); });
} }
@@ -504,11 +612,7 @@ function collectObjectFields(
type: ts.Type, type: ts.Type,
currentPath: string, currentPath: string,
entries: FieldEntry[], entries: FieldEntry[],
options: { options: TraversalOptions
condition: string | null;
skipProperties: ReadonlySet<string>;
skipPropertiesForThisObject?: ReadonlySet<string>;
}
): void { ): void {
for (const property of type.getProperties()) { for (const property of type.getProperties()) {
const propertyName = property.name; const propertyName = property.name;
@@ -525,7 +629,8 @@ function collectObjectFields(
collectFields(propertyType, `${currentPath}.${propertyName}`, entries, { collectFields(propertyType, `${currentPath}.${propertyName}`, entries, {
condition: options.condition, condition: options.condition,
skipProperties: options.skipProperties skipProperties: options.skipProperties,
includeIdentityProperties: options.includeIdentityProperties
}); });
} }
} }
@@ -574,7 +679,116 @@ function wrapFieldList(fields: readonly string[], indent = " "): string[] {
return lines; return lines;
} }
const groupedFields = ROOTS.map((root) => { function collectRootFields(
roots: readonly FieldRoot[],
includeIdentityProperties: boolean
) {
return roots.map((root) => {
const entries: FieldEntry[] = [];
collectFields(getRootType(root), root.path, entries, {
condition: null,
skipProperties: new Set(root.skipProperties ?? []),
includeIdentityProperties
});
return {
title: root.title,
fields: uniqueEntries(entries).map(formatEntry)
};
});
}
function parseScope(args: readonly string[]): InventoryScope {
if (args.includes("--help") || args.includes("-h")) {
process.stdout.write(
[
"Usage: list-authorable-fields [--authorable-only | --runtime-only | --include-runtime]",
"",
"Default: --authorable-only",
"--runtime-only List exported Runtime* field roots from runtime/control code.",
"--include-runtime List authorable fields first, then runtime fields."
].join("\n") + "\n"
);
process.exit(0);
}
if (args.includes("--runtime-only")) {
return "runtime";
}
if (args.includes("--include-runtime") || args.includes("--all")) {
return "all";
}
return "authorable";
}
function createGroupedFields(scope: InventoryScope) {
switch (scope) {
case "authorable":
return {
title: "Authorable field inventory",
source:
"Source: current canonical TypeScript authoring schemas",
excludes:
"Excludes: ids, version, kind discriminators, textures, and generated imported-asset metadata/storage fields.",
groups: collectRootFields(AUTHORABLE_ROOTS, false)
};
case "runtime": {
const runtimeRoots = discoverRuntimeRoots();
return {
title: "Runtime field inventory",
source:
"Source: exported Runtime* TypeScript types in src/runtime-three and src/controls/control-surface.ts",
excludes:
"Includes ids and discriminators. Omits primitive-only Runtime* aliases because they have no object fields.",
groups: collectRootFields(runtimeRoots, true)
};
}
case "all": {
const runtimeRoots = discoverRuntimeRoots();
return {
title: "Authorable and runtime field inventory",
source:
"Source: canonical authoring schemas plus exported Runtime* TypeScript types.",
excludes:
"Authorable groups exclude ids/version/kind discriminators; runtime groups include ids and discriminators.",
groups: [
...collectRootFields(AUTHORABLE_ROOTS, false).map((group) => ({
...group,
title: `Authorable: ${group.title}`
})),
...collectRootFields(runtimeRoots, true).map((group) => ({
...group,
title: `Runtime: ${group.title}`
}))
]
};
}
}
}
const inventory = createGroupedFields(parseScope(process.argv.slice(2)));
const groupedFields = inventory.groups;
const totalFieldCount = groupedFields.reduce(
(sum, group) => sum + group.fields.length,
0
);
const lines = [
inventory.title,
inventory.source,
inventory.excludes,
`Total: ${totalFieldCount} field paths across ${groupedFields.length} groups.`,
""
];
for (const group of groupedFields) {
lines.push(`${group.title} (${group.fields.length})`);
lines.push(...wrapFieldList(group.fields));
lines.push("");
}
process.stdout.write(`${lines.join("\n").trimEnd()}\n`);
const entries: FieldEntry[] = []; const entries: FieldEntry[] = [];
collectFields(getRootType(root), root.path, entries, { collectFields(getRootType(root), root.path, entries, {
condition: null, condition: null,