/** * Verify client package modes, the synchronous browser module-request graph, and package-asset publication. */ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, posix, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' import { transform } from 'lightningcss' import ts from 'typescript' import { PACKAGE_ASSET_EXPORT, PACKAGE_ASSET_EXPORT_TARGET, PACKAGE_ASSET_FILES_ENTRY, packageAssetMediaType, } from '../packages/client/modules/src/assets.ts' import { TypeScriptProject } from './ts-project.ts' export { collectLocalSourceSpecifiers, collectRuntimeLocalSourceSpecifiers, collectRuntimeSourcePackageUses, collectRuntimeSourceSpecifiers, collectSourcePackageUses, } from './source-parse-uses.ts' import { collectSourceFileUses } from './source-parse-uses.ts' const GATE = 'verify-client-packages' const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json' const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'] const CONFIG_GLOB = 'packages/*/*/tsdown.config.ts' const PLATFORM_SOURCE = 'packages/client/web/src/platform.ts' const PARSER_PRELOAD_SOURCE = 'packages/client/modules/src/index.ts' const STATIC_PRESET_SOURCE = 'packages/client/tsdown.client.ts' const CORDIS = '@deepseek-ai/cordis' /** Stylesheet requests the dynamic build preset compiles: CSS Modules, global sheets, and `?inline` text. */ const STYLESHEET_REQUEST = /\.css(?:\?inline)?$/u const INLINE_STYLESHEET_QUERY = /\?inline$/u /** One source location whose reference makes the dynamic build preset emit a package asset. */ export interface AssetReference { /** Repository-relative, `/`-separated source or stylesheet path. */ readonly file: string /** The JSON-quoted module specifier, or `url("")` for a stylesheet reference. */ readonly reference: string } /** One workspace package's browser-module declaration. */ export interface ClientDeclaration { readonly name: string readonly manifest: string readonly dynamic: boolean /** String `dsh.client.platform`; undefined without a row or a string platform. */ readonly platform: string | undefined readonly external: readonly string[] readonly runtimeSourceUses: Readonly> /** Exact runtime specifiers used to validate `dsh.client.external` declarations. */ readonly runtimeSourceSpecifiers: Readonly> /** Informational package dependencies declared by the row. */ readonly inject: readonly string[] /** Raw `exports["./assets/*"]` value; undefined when the manifest declares no such subpath. */ readonly assetExport: unknown /** * Package-asset references in a dynamic Web row's Client sources under `src/` and in the package stylesheets they * import, sorted by file and then reference; empty for every other package. */ readonly assetReferences: readonly AssetReference[] } /** One package directly under packages/client. */ export interface ClientPackage extends ClientDeclaration { readonly staticLinked: boolean readonly sourceUses: Readonly> readonly dependencies: Readonly> readonly peerDependencies: Readonly> readonly devDependencies: Readonly> } /** Complete source-plane input to the client package verifier. */ export interface ClientPackageFacts { readonly packages: readonly ClientPackage[] readonly declarations: readonly ClientDeclaration[] readonly staticLinkedPackages: ReadonlySet readonly platformModules: readonly string[] readonly preloadedExternals: readonly string[] readonly parserPreloadIds: readonly string[] readonly malformed: readonly string[] } /** Result of reading every workspace browser-module declaration. */ export interface ClientDeclarations { readonly declarations: ClientDeclaration[] readonly malformed: string[] } /** * Collect bare packages referenced by one production source file. * @param path - File path used to select TypeScript's parser mode. * @param source - Source text to inspect. * @returns Bare package names referenced by imports, declarations, or JSX. */ export function collectSourceAssetSpecifiers(path: string, source: string): Set { const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true) return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier)) } function isAssetSpecifier(specifier: string): boolean { return packageAssetMediaType(specifier) !== undefined } /** * Collect the relative `url()` values of one stylesheet, which the dynamic build preset resolves to package assets. * @param path - Stylesheet path; a `.module.css` name enables CSS Modules syntax, as in the build preset. * @param source - Stylesheet text. * @returns `url()` values in source order, excluding empty values, fragments, root-relative paths, and URLs with a * scheme such as `data:` or `https:`. * @throws when lightningcss cannot parse the stylesheet; the message names `path`. */ export function collectStylesheetAssetUrls(path: string, source: string): string[] { const urls: string[] = [] try { transform({ filename: path, code: Buffer.from(source), cssModules: path.endsWith('.module.css'), visitor: { Url(url) { if (isRelativeStylesheetUrl(url.url)) urls.push(url.url) }, // lightningcss does not visit the urls inside image-set(), so its options are read here. Image(image) { if (image.type !== 'image-set') return for (const option of image.value.options) { if (option.image.type === 'url' && isRelativeStylesheetUrl(option.image.value.url)) urls.push(option.image.value.url) } }, }, }) } catch (error) { throw new Error( GATE + ': cannot parse stylesheet ' + path + ': ' + (error instanceof Error ? error.message : String(error)), { cause: error }, ) } return urls } function isRelativeStylesheetUrl(url: string): boolean { return url !== '' && !url.startsWith('#') && !url.startsWith('/') && !/^[a-z][a-z\d+.-]*:/iu.test(url) } /** * Read package-asset references from the package stylesheets that Client sources import. The dynamic build preset * compiles exactly these stylesheets; a stylesheet no Client source imports, such as a copied static payload, keeps * its `url()` values unchanged. * @param root - Absolute repository root. * @param packageDirectory - Repository-relative, `/`-separated package directory. * @param importers - Repository-relative, `/`-separated Client source paths and their runtime local module specifiers. * @returns Imported `.css` and `.css?inline` stylesheets under the package's `src/`, in path order, and their references. */ export function readStylesheetAssetReferences( root: string, packageDirectory: string, importers: ReadonlyMap>, ): { readonly stylesheets: readonly string[]; readonly references: readonly AssetReference[] } { const sourcePrefix = packageDirectory + '/src/' const imported = new Set() for (const [file, specifiers] of importers) { for (const specifier of specifiers) { if (!specifier.startsWith('.') || !STYLESHEET_REQUEST.test(specifier)) continue const stylesheet = posix.join(posix.dirname(file), specifier.replace(INLINE_STYLESHEET_QUERY, '')) if (stylesheet.startsWith(sourcePrefix)) imported.add(stylesheet) } } const stylesheets = [...imported].sort() return { stylesheets, references: stylesheets.flatMap(file => collectStylesheetAssetUrls(file, readFileSync(resolve(root, file), 'utf8')) .map(url => ({ file, reference: 'url(' + JSON.stringify(url) + ')' }))), } } export function readClientDeclarations(root: string): ClientDeclarations { const malformed: string[] = [] const declarations = globSync(MANIFEST_GLOBS, { cwd: root }) .map(normalizePath) .sort() .flatMap(path => readDeclaration(root, path, malformed) ?? []) return { declarations, malformed } } /** * Return every client package policy violation. * @param facts - Package modes, manifests, source uses, package-asset references, and platform module lists. * @returns Stable self-contained diagnostics. */ export function collectClientPackageViolations(facts: ClientPackageFacts): string[] { return [ ...facts.malformed, ...collectModeViolations(facts), ...collectModuleViolations(facts), ...collectAssetViolations(facts), ].sort((left, right) => left.localeCompare(right)) } interface ManifestDocument { readonly path: string readonly manifest: Manifest changed: boolean } /** * Repair malformed or redundant `dsh.client` declaration entries. * @param root - Absolute repository root. * @param facts - Facts used by the verification pass. * @returns Repository-relative manifests written by the fixer. */ export function fixClientPackageManifests(root: string, facts: ClientPackageFacts): string[] { const documents = new Map() const document = (path: string): ManifestDocument => { const cached = documents.get(path) if (cached !== undefined) return cached const loaded: ManifestDocument = { path, manifest: JSON.parse(readFileSync(resolve(root, path), 'utf8')) as Manifest, changed: false, } documents.set(path, loaded) return loaded } const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals]) for (const declaration of facts.declarations.filter(entry => entry.dynamic)) { const target = document(declaration.manifest) const dsh = isRecord(target.manifest.dsh) ? target.manifest.dsh : undefined const client = isRecord(dsh?.client) ? dsh.client : undefined if (client === undefined) continue target.changed = normalizeClientArray(client, 'inject', () => false) || target.changed target.changed = normalizeClientArray( client, 'external', value => baseline.has(value) || rowPackageOf(value, new Set([declaration.name])) === declaration.name, ) || target.changed } const changed = [...documents.values()].filter(target => target.changed).sort((left, right) => left.path.localeCompare(right.path)) for (const target of changed) { writeFileSync(resolve(root, target.path), JSON.stringify(target.manifest, null, 2) + '\n') } return changed.map(target => target.path) } function normalizeClientArray( client: Record, field: 'external' | 'inject', remove: (value: string) => boolean, ): boolean { const value = client[field] if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) return false const seen = new Set() const normalized = value.filter((entry: string) => { if (entry === '' || seen.has(entry) || remove(entry)) return false seen.add(entry) return true }) if (normalized.length === value.length && normalized.every((entry, index) => entry === value[index])) return false if (normalized.length === 0) { if (field === 'external') delete client.external else delete client.inject } else { client[field] = normalized } return true } function collectModeViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] for (const pkg of facts.packages) { if (pkg.dynamic && pkg.staticLinked) { violations.push( pkg.manifest + ': ' + pkg.name + ' declares dsh.client and uses the staticLinked preset;' + ' a client package must be dynamic or statically linked, not both', ) } else if (!pkg.dynamic && !pkg.staticLinked) { violations.push( pkg.manifest + ': ' + pkg.name + ' has no supported client package mode;' + ' declare dsh.client or use the staticLinked preset', ) } } const workspaceNames = new Set(facts.declarations.map(entry => entry.name)) for (const specifier of facts.platformModules) { const owner = packageNameOf(specifier) if (!workspaceNames.has(owner) || owner === CORDIS || facts.staticLinkedPackages.has(owner)) continue violations.push( PLATFORM_SOURCE + ': seeded workspace module ' + JSON.stringify(specifier) + ' belongs to ' + owner + ', whose build does not use the staticLinked preset', ) } const rows = rowNames(facts.declarations) for (const specifier of facts.preloadedExternals) { if (rowPackageOf(specifier, rows) === undefined) { violations.push( PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier) + ' has no dynamic dsh.client row', ) } if (!facts.parserPreloadIds.includes(stripClientSuffix(specifier))) { violations.push( PLATFORM_SOURCE + ': parser-preloaded external ' + JSON.stringify(specifier) + ' has no matching PARSER_PRELOAD_IDS row in ' + PARSER_PRELOAD_SOURCE, ) } } return violations } interface ModuleEdge { readonly from: string readonly to: string readonly specifier: string } function collectModuleViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] const baseline = new Set([...facts.platformModules, ...facts.preloadedExternals]) const rows = rowNames(facts.declarations) const byName = new Map(facts.declarations.map(entry => [entry.name, entry])) const edges: ModuleEdge[] = [] for (const pkg of facts.declarations.filter(entry => entry.dynamic)) { for (const field of ['external', 'inject'] as const) { const seen = new Set() for (const value of pkg[field]) { if (value === '') violations.push(pkg.manifest + ': dsh.client.' + field + ' contains an empty value') else if (seen.has(value)) { violations.push(pkg.manifest + ': dsh.client.' + field + ' lists ' + JSON.stringify(value) + ' twice') } seen.add(value) } } for (const specifier of new Set(pkg.external)) { if (specifier === '') continue if (baseline.has(specifier)) { violations.push( pkg.manifest + ': dsh.client.external repeats baseline module ' + JSON.stringify(specifier) + '; remove the explicit declaration', ) continue } const supplier = rowPackageOf(specifier, rows) if (supplier === pkg.name) { violations.push(pkg.manifest + ': dsh.client.external names its own row ' + JSON.stringify(specifier)) } else if (supplier !== undefined) { if (pkg.manifest.startsWith('packages/client/')) { violations.push( pkg.manifest + ': client feature package requests runtime external ' + JSON.stringify(specifier) + '; import shared types only or call an injected Cordis service', ) continue } if (pkg.runtimeSourceSpecifiers[specifier] === undefined) { violations.push( pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no runtime import or re-export in production source; remove the stale declaration', ) continue } edges.push({ from: pkg.name, to: supplier, specifier }) } else { const owner = stripClientSuffix(specifier) violations.push( pkg.manifest + ': dsh.client.external ' + JSON.stringify(specifier) + ' has no supplier;' + (byName.has(owner) ? ' workspace package ' + owner + ' declares no dynamic dsh.client row and the shell does not seed this specifier' : ' no dynamic row or PLATFORM_MODULES entry answers it'), ) } } } violations.push(...collectModuleCycles(edges, byName)) return violations } function collectModuleCycles( edges: readonly ModuleEdge[], byName: ReadonlyMap, ): string[] { const outgoing = new Map() for (const edge of [...edges].sort((left, right) => left.specifier.localeCompare(right.specifier))) { outgoing.set(edge.from, [...outgoing.get(edge.from) ?? [], edge]) } const finished = new Set() const onPath = new Set() const path: ModuleEdge[] = [] const reported = new Map() const walk = (name: string): void => { onPath.add(name) for (const edge of outgoing.get(name) ?? []) { if (onPath.has(edge.to)) { const start = path.findIndex(entry => entry.from === edge.to) const cycle = start === -1 ? [edge] : [...path.slice(start), edge] const key = cycleKey(cycle) if (!reported.has(key)) reported.set(key, formatCycle(cycle, byName)) } else if (!finished.has(edge.to)) { path.push(edge) walk(edge.to) path.pop() } } onPath.delete(name) finished.add(name) } for (const name of [...outgoing.keys()].sort()) { if (!finished.has(name)) walk(name) } return [...reported.values()] } function cycleKey(cycle: readonly ModuleEdge[]): string { const labels = cycle.map(edge => edge.from + ' ' + edge.specifier) const first = [...labels].sort()[0] const offset = first === undefined ? 0 : labels.indexOf(first) return [...labels.slice(offset), ...labels.slice(0, offset)].join(' -> ') } function formatCycle( cycle: readonly ModuleEdge[], byName: ReadonlyMap, ): string { const entry = cycle[0] const chain = cycle.map(edge => edge.from + ' --(' + edge.specifier + ')-->').join(' ') const manifest = entry === undefined ? 'packages/client' : byName.get(entry.from)?.manifest ?? entry.from return manifest + ': synchronous dsh.client.external cycle: ' + chain + ' ' + (entry?.from ?? '') } /** Whether the dynamic build preset builds this package's browser face, the only face that emits package assets. */ function buildsDynamicWebClient(declaration: ClientDeclaration): boolean { return declaration.dynamic && declaration.platform === 'web' } function collectAssetViolations(facts: ClientPackageFacts): string[] { const violations: string[] = [] const exportKey = 'exports[' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ']' for (const pkg of facts.declarations) { const declared = pkg.assetExport !== undefined const covered = buildsDynamicWebClient(pkg) if (declared && pkg.assetExport !== PACKAGE_ASSET_EXPORT_TARGET) { violations.push( pkg.manifest + ': ' + exportKey + ' is ' + JSON.stringify(pkg.assetExport) + '; it must be exactly ' + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET), ) } if (declared && !covered) { violations.push( pkg.manifest + ': ' + pkg.name + ' declares ' + exportKey + ' without a dsh.client row for platform "web";' + ' only dynamic Web Client packages publish package assets', ) } if (!covered) continue const first = pkg.assetReferences[0] if (!declared && first !== undefined) { violations.push( pkg.manifest + ': ' + pkg.name + ' references package asset ' + first.reference + ' in ' + first.file + ' but declares no ' + exportKey + '; add ' + JSON.stringify(PACKAGE_ASSET_EXPORT) + ': ' + JSON.stringify(PACKAGE_ASSET_EXPORT_TARGET) + ' and publish ' + JSON.stringify(PACKAGE_ASSET_FILES_ENTRY), ) } else if (declared && first === undefined) { violations.push( pkg.manifest + ': ' + pkg.name + ' declares an unused ' + PACKAGE_ASSET_EXPORT + ' export;' + ' no Client source or stylesheet it imports references a package asset', ) } } return violations } interface Manifest { name?: unknown dsh?: unknown exports?: unknown dependencies?: Record peerDependencies?: Record devDependencies?: Record } function readDeclaration( root: string, manifestPath: string, malformed: string[], ): ClientDeclaration | undefined { const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest if (typeof manifest.name !== 'string') return undefined const dsh = isRecord(manifest.dsh) ? manifest.dsh : undefined const rawClient = dsh?.client const assetExport = isRecord(manifest.exports) ? manifest.exports[PACKAGE_ASSET_EXPORT] : undefined const rowless = { name: manifest.name, manifest: manifestPath, dynamic: false, platform: undefined, external: [], inject: [], runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, assetExport, assetReferences: [], } satisfies ClientDeclaration if (rawClient === undefined) return rowless if (!isRecord(rawClient)) { malformed.push(manifestPath + ': ' + manifest.name + ' dsh.client must be an object') return rowless } return { name: manifest.name, manifest: manifestPath, dynamic: true, platform: typeof rawClient.platform === 'string' ? rawClient.platform : undefined, external: stringArray(rawClient.external, manifest.name, manifestPath, 'external', malformed), inject: stringArray(rawClient.inject, manifest.name, manifestPath, 'inject', malformed), runtimeSourceUses: {}, runtimeSourceSpecifiers: {}, assetExport, assetReferences: [], } } function stringArray( value: unknown, packageName: string, manifestPath: string, field: string, malformed: string[], ): readonly string[] { if (value === undefined) return [] if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string')) { malformed.push(manifestPath + ': ' + packageName + ' dsh.client.' + field + ' must be a string array') return [] } return value as string[] } async function readStaticLinkedRoster(root: string): Promise> { const presetUrl = pathToFileURL(resolve(import.meta.dirname, '..', STATIC_PRESET_SOURCE)).href const preset = await import(presetUrl) as { isStaticLinkedConfig?: unknown } if (typeof preset.isStaticLinkedConfig !== 'function') { throw new Error(GATE + ': ' + STATIC_PRESET_SOURCE + ' exports no isStaticLinkedConfig') } const predicate = preset.isStaticLinkedConfig as (configs: readonly unknown[]) => boolean const roster = new Set() for (const configPath of globSync(CONFIG_GLOB, { cwd: root }).map(normalizePath).sort()) { const loaded = await import(pathToFileURL(resolve(root, configPath)).href) as { default?: unknown } if (typeof loaded.default !== 'function') continue const configs = (loaded.default as (input: { env: Record }) => unknown)({ env: { DSH_BUILD_FACE: 'client' }, }) if (!Array.isArray(configs) || !predicate(configs)) continue const manifest = JSON.parse( readFileSync(resolve(root, configPath.replace(/tsdown\.config\.ts$/, 'package.json')), 'utf8'), ) as Manifest if (typeof manifest.name === 'string') roster.add(manifest.name) } return roster } function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current)) { current = current.expression } return current } function readStringLiteralArray(root: string, sourcePath: string, name: string): string[] { const path = resolve(root, sourcePath) const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, false, ts.ScriptKind.TS) const constants = new Map() for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || declaration.initializer === undefined) continue const initializer = unwrapExpression(declaration.initializer) if (ts.isStringLiteral(initializer)) constants.set(declaration.name.text, initializer.text) } } for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue for (const declaration of statement.declarationList.declarations) { if (!ts.isIdentifier(declaration.name) || declaration.name.text !== name) continue const expression = declaration.initializer === undefined ? undefined : unwrapExpression(declaration.initializer) if (expression === undefined || !ts.isArrayLiteralExpression(expression)) { throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must be an array literal') } return expression.elements.map((element) => { const value = unwrapExpression(element) if (ts.isStringLiteral(value)) return value.text if (ts.isIdentifier(value) && constants.has(value.text)) return constants.get(value.text) as string throw new Error(GATE + ': ' + name + ' in ' + sourcePath + ' must contain only string constants') }) } } throw new Error(GATE + ': ' + sourcePath + ' declares no ' + name) } async function readFacts(root: string): Promise { const { declarations: bareDeclarations, malformed } = readClientDeclarations(root) const staticLinkedPackages = await readStaticLinkedRoster(root) const project = new TypeScriptProject(root, 'client') const sourceFiles = project.sourceFiles() let webStylesheets = 0 const declarations = bareDeclarations.map((declaration): ClientDeclaration => { const runtimeSourceUses = new Map>() const runtimeSourceSpecifiers = new Map>() const assetReferences: AssetReference[] = [] const scansAssets = buildsDynamicWebClient(declaration) const importers = new Map>() const sourcePrefix = dirname(declaration.manifest) + '/src/' for (const sourceFile of sourceFiles) { if (sourceFile.isDeclarationFile) continue const file = project.relativePath(sourceFile) if (!file.startsWith(sourcePrefix)) continue for (const name of collectSourceFileUses(sourceFile, true, 'package')) { const locations = runtimeSourceUses.get(name) ?? new Set() locations.add(file) runtimeSourceUses.set(name, locations) } for (const specifier of collectSourceFileUses(sourceFile, true, 'specifier')) { const locations = runtimeSourceSpecifiers.get(specifier) ?? new Set() locations.add(file) runtimeSourceSpecifiers.set(specifier, locations) } if (!scansAssets) continue const localSpecifiers = collectSourceFileUses(sourceFile, true, 'local') importers.set(file, localSpecifiers) for (const specifier of localSpecifiers) { if (isAssetSpecifier(specifier)) assetReferences.push({ file, reference: JSON.stringify(specifier) }) } } if (scansAssets) { // A Web row always has a browser half, so an empty source set means the Client program stopped covering it. if (importers.size === 0) { throw new Error( GATE + ': the Client program loads no source under ' + sourcePrefix + ' for dynamic Web row ' + declaration.name + '; its package-asset references cannot be read', ) } const stylesheets = readStylesheetAssetReferences(root, dirname(declaration.manifest), importers) webStylesheets += stylesheets.stylesheets.length assetReferences.push(...stylesheets.references) } return { ...declaration, runtimeSourceUses: Object.fromEntries( [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right)) .map(([name, locations]) => [name, [...locations].sort()]), ), runtimeSourceSpecifiers: Object.fromEntries( [...runtimeSourceSpecifiers].sort(([left], [right]) => left.localeCompare(right)) .map(([specifier, locations]) => [specifier, [...locations].sort()]), ), assetReferences: assetReferences.sort((left, right) => left.file.localeCompare(right.file) || left.reference.localeCompare(right.reference)), } }) if (webStylesheets === 0) { throw new Error(GATE + ': no dynamic Web row imports a stylesheet from its src/ directory; the stylesheet scan read an empty corpus') } const byManifest = new Map(declarations.map(entry => [entry.manifest, entry])) const packages: ClientPackage[] = [] for (const manifestPath of globSync(CLIENT_MANIFEST_GLOB, { cwd: root }).map(normalizePath).sort()) { const declaration = byManifest.get(manifestPath) if (declaration === undefined) throw new Error(GATE + ': no declaration facts for ' + manifestPath) const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8')) as Manifest if (typeof manifest.name !== 'string') throw new Error(GATE + ': ' + manifestPath + ' has no package name') const sourceUses = new Map>() const runtimeSourceUses = new Map>() const packageDirectory = dirname(manifestPath) const sourcePrefix = packageDirectory + '/src/' for (const sourceFile of sourceFiles) { if (sourceFile.isDeclarationFile) continue const file = project.relativePath(sourceFile) if (!file.startsWith(sourcePrefix)) continue for (const name of collectSourceFileUses(sourceFile, false, 'package')) { const locations = sourceUses.get(name) ?? new Set() locations.add(file) sourceUses.set(name, locations) } for (const name of collectSourceFileUses(sourceFile, true, 'package')) { const locations = runtimeSourceUses.get(name) ?? new Set() locations.add(file) runtimeSourceUses.set(name, locations) } } packages.push({ ...declaration, staticLinked: staticLinkedPackages.has(declaration.name), sourceUses: Object.fromEntries( [...sourceUses].sort(([left], [right]) => left.localeCompare(right)) .map(([name, locations]) => [name, [...locations].sort()]), ), runtimeSourceUses: Object.fromEntries( [...runtimeSourceUses].sort(([left], [right]) => left.localeCompare(right)) .map(([name, locations]) => [name, [...locations].sort()]), ), dependencies: manifest.dependencies ?? {}, peerDependencies: manifest.peerDependencies ?? {}, devDependencies: manifest.devDependencies ?? {}, }) } return { packages, declarations, staticLinkedPackages, platformModules: readStringLiteralArray(root, PLATFORM_SOURCE, 'PLATFORM_MODULES'), preloadedExternals: readStringLiteralArray(root, PLATFORM_SOURCE, 'PRELOADED_CLIENT_EXTERNALS'), parserPreloadIds: readStringLiteralArray(root, PARSER_PRELOAD_SOURCE, 'PARSER_PRELOAD_IDS'), malformed, } } function packageNameOf(specifier: string): string { const segments = specifier.split('/') return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/') } function stripClientSuffix(specifier: string): string { return specifier.endsWith('/client') ? specifier.slice(0, -'/client'.length) : specifier } function rowNames(declarations: readonly ClientDeclaration[]): Set { return new Set(declarations.filter(entry => entry.dynamic).map(entry => entry.name)) } function rowPackageOf(specifier: string, rows: ReadonlySet): string | undefined { if (rows.has(specifier)) return specifier const stripped = stripClientSuffix(specifier) return rows.has(stripped) ? stripped : undefined } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function normalizePath(path: string): string { return path.split(sep).join('/') } async function main(): Promise { const root = resolve(import.meta.dirname, '..') let facts = await readFacts(root) if (process.argv.includes('--fix')) { const changed = fixClientPackageManifests(root, facts) console.log( changed.length === 0 ? GATE + ': no mechanically fixable manifest changes.' : GATE + ': fixed ' + String(changed.length) + ' manifest(s): ' + changed.join(', '), ) facts = await readFacts(root) } const violations = collectClientPackageViolations(facts) if (violations.length > 0) { console.error(GATE + ': ' + String(violations.length) + ' violation(s):') for (const violation of violations) console.error(' ' + violation) process.exit(1) } const dynamic = facts.packages.filter(pkg => pkg.dynamic).length const requests = facts.declarations.reduce((total, pkg) => total + pkg.external.length, 0) const assetExports = facts.declarations.filter(pkg => pkg.assetExport !== undefined).length console.log( GATE + ': ' + String(facts.packages.length) + ' client packages (' + String(dynamic) + ' dynamic, ' + String(facts.packages.length - dynamic) + ' statically linked) satisfy package-mode, module-request, and ' + 'package-asset rules; ' + String(requests) + ' explicit external request(s), ' + String(assetExports) + ' package-asset export(s).', ) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { await main() }