mirror of
https://github.com/aipoch/open-science.git
synced 2026-10-02 02:14:40 +08:00
* refactor(renderer): group contracts and literature workflows Split renderer contracts by capability behind the existing public catalog, preserving API definitions, registration order and generated Web mappings. Group Literature views and workflows with their tests and module ownership. Reuse table-level rating labels and render the existing type label directly to reduce repeated work when search clearing restores the result rows. Keep data formats, state values and persistence behavior unchanged. Validate module and focused contract tests, production builds, 59 browser cases, 12 Electron cases and ownership/consumer guards. * ci(renderer): preserve contract coverage after directory split Keep the split renderer contract definitions in the Windows-sensitive selection used by their former shared catalog. Cover the facade and capability file routing without reducing existing CI lanes. Update the Session renderer architecture expectation with the added Literature localization consumer test so it matches the registered graph. Validate 199 focused classifier, architecture, integrity and registration tests, module ownership, formatting and lint. Production behavior is unchanged.
190 lines
7.3 KiB
JavaScript
190 lines
7.3 KiB
JavaScript
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { format } from 'prettier'
|
|
import ts from 'typescript'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
const catalogPath = resolve(root, 'src/shared/renderer-contract-catalog.ts')
|
|
const outputPath = resolve(root, 'src/shared/web-api-map.generated.ts')
|
|
|
|
const stringLiteral = (node, label) => {
|
|
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
|
|
if (ts.isIdentifier(node)) return node.text
|
|
throw new Error(`Renderer contract ${label} must be a string literal.`)
|
|
}
|
|
|
|
// prettier-ignore
|
|
const invokeProfiles = new Set(['WEB', 'LOCAL', 'MAPPED_ELECTRON', 'MAPPED_NATIVE', 'DELEGATED_NATIVE'])
|
|
const eventProfiles = new Set(['EVENT'])
|
|
// prettier-ignore
|
|
const unprojectedProfiles = new Set(['ELECTRON', 'SEND', 'WINDOW_FIND_READY', 'ELECTRON_EVENT', 'CLOSE_PANE_EVENT', 'NATIVE'])
|
|
|
|
const projectionFor = (node) => {
|
|
if (!node) return 'invoke'
|
|
if (!ts.isIdentifier(node)) throw new Error('Renderer contract profile must be an identifier.')
|
|
if (invokeProfiles.has(node.text)) return 'invoke'
|
|
if (eventProfiles.has(node.text)) return 'event'
|
|
if (unprojectedProfiles.has(node.text)) return 'none'
|
|
throw new Error(`Unknown renderer contract profile: ${node.text}`)
|
|
}
|
|
|
|
// Follow explicit contract constants only; never execute catalog code or scan directories.
|
|
async function readContractEntries(catalogPath) {
|
|
const sources = new Map()
|
|
const visiting = new Set()
|
|
async function load(path) {
|
|
if (sources.has(path)) return sources.get(path)
|
|
const source = ts.createSourceFile(
|
|
path,
|
|
await readFile(path, 'utf8'),
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const definitions = new Map()
|
|
const imports = new Map()
|
|
for (const statement of source.statements) {
|
|
if (ts.isVariableStatement(statement)) {
|
|
for (const declaration of statement.declarationList.declarations) {
|
|
if (ts.isIdentifier(declaration.name))
|
|
definitions.set(declaration.name.text, declaration.initializer)
|
|
}
|
|
}
|
|
if (
|
|
ts.isImportDeclaration(statement) &&
|
|
statement.importClause?.namedBindings &&
|
|
ts.isNamespaceImport(statement.importClause.namedBindings)
|
|
) {
|
|
imports.set(statement.importClause.namedBindings.name.text, statement.moduleSpecifier.text)
|
|
}
|
|
}
|
|
const result = { path, definitions, imports }
|
|
sources.set(path, result)
|
|
return result
|
|
}
|
|
async function definition(source, name) {
|
|
const key = `${source.path}:${name}`
|
|
if (visiting.has(key)) throw new Error(`Circular renderer contract definition: ${name}`)
|
|
const node = source.definitions.get(name)
|
|
if (!node) throw new Error(`Renderer contract definition not found: ${name}`)
|
|
visiting.add(key)
|
|
try {
|
|
return await entries(source, node)
|
|
} finally {
|
|
visiting.delete(key)
|
|
}
|
|
}
|
|
async function entries(source, node) {
|
|
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node))
|
|
return entries(source, node.expression)
|
|
if (ts.isIdentifier(node)) return definition(source, node.text)
|
|
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
|
|
const specifier = source.imports.get(node.expression.text)
|
|
if (!specifier?.startsWith('./'))
|
|
throw new Error('Renderer contract imports must be explicit local modules.')
|
|
return definition(
|
|
await load(resolve(dirname(source.path), `${specifier}.ts`)),
|
|
node.name.text
|
|
)
|
|
}
|
|
if (ts.isCallExpression(node)) {
|
|
if (
|
|
ts.isIdentifier(node.expression) &&
|
|
node.expression.text === 'composeRendererApiContract'
|
|
) {
|
|
const result = []
|
|
for (const argument of node.arguments) result.push(...(await entries(source, argument)))
|
|
return result
|
|
}
|
|
if (
|
|
ts.isPropertyAccessExpression(node.expression) &&
|
|
ts.isIdentifier(node.expression.expression) &&
|
|
node.expression.expression.text === 'Object' &&
|
|
node.expression.name.text === 'freeze' &&
|
|
node.arguments.length === 1
|
|
) {
|
|
return entries(source, node.arguments[0])
|
|
}
|
|
}
|
|
if (ts.isObjectLiteralExpression(node)) {
|
|
const result = []
|
|
for (const property of node.properties) {
|
|
if (ts.isSpreadAssignment(property))
|
|
result.push(...(await entries(source, property.expression)))
|
|
else if (ts.isPropertyAssignment(property)) result.push(property)
|
|
else
|
|
throw new Error(
|
|
'Renderer contract may only contain property assignments or explicit spreads.'
|
|
)
|
|
}
|
|
return result
|
|
}
|
|
throw new Error('Unsupported renderer contract definition.')
|
|
}
|
|
return definition(await load(catalogPath), 'RENDERER_API_CONTRACT')
|
|
}
|
|
|
|
export async function generateRendererApiMap(catalogPath) {
|
|
const invoke = {}
|
|
const events = {}
|
|
const publicPaths = new Set()
|
|
for (const property of await readContractEntries(catalogPath)) {
|
|
const path = stringLiteral(property.name, 'public path')
|
|
if (publicPaths.has(path)) throw new Error(`Duplicate renderer contract path: ${path}`)
|
|
publicPaths.add(path)
|
|
const initializer = property.initializer
|
|
if (
|
|
!ts.isCallExpression(initializer) ||
|
|
!ts.isCallExpression(initializer.expression) ||
|
|
!ts.isIdentifier(initializer.expression.expression)
|
|
) {
|
|
throw new Error(`Renderer contract entry must use a typed builder: ${path}`)
|
|
}
|
|
const builder = initializer.expression.expression.text
|
|
if (builder === 'value') continue
|
|
if (builder !== 'callable') throw new Error(`Unknown renderer contract builder: ${builder}`)
|
|
const metadata = initializer.arguments[1]
|
|
if (!metadata || !ts.isArrayLiteralExpression(metadata)) {
|
|
throw new Error(`Renderer contract metadata must be a tuple: ${path}`)
|
|
}
|
|
const projection = projectionFor(metadata.elements[1])
|
|
if (projection === 'none') continue
|
|
const channel = stringLiteral(metadata.elements[0], 'channel')
|
|
const target = projection === 'invoke' ? invoke : events
|
|
if (Object.hasOwn(target, path)) throw new Error(`Duplicate projected renderer path: ${path}`)
|
|
target[path] = channel
|
|
}
|
|
|
|
const sorted = (value) =>
|
|
Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
|
return format(
|
|
`// Generated by scripts/generate-web-api-map.mjs. Do not edit by hand.
|
|
export const WEB_INVOKE_CHANNELS = ${JSON.stringify(sorted(invoke), null, 2)} as const
|
|
|
|
export const WEB_EVENT_CHANNELS = ${JSON.stringify(sorted(events), null, 2)} as const
|
|
`,
|
|
{
|
|
parser: 'typescript',
|
|
singleQuote: true,
|
|
semi: false,
|
|
printWidth: 100,
|
|
trailingComma: 'none'
|
|
}
|
|
)
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const output = await generateRendererApiMap(catalogPath)
|
|
if (process.argv.includes('--check')) {
|
|
const current = await readFile(outputPath, 'utf8').catch(() => '')
|
|
if (current !== output) {
|
|
console.error('Web API map is stale. Run npm run gen:web-api-map.')
|
|
process.exitCode = 1
|
|
}
|
|
} else {
|
|
await writeFile(outputPath, output, 'utf8')
|
|
console.log(`Generated ${outputPath}`)
|
|
}
|
|
}
|