diff --git a/packages/compat/src/audit.ts b/packages/compat/src/audit.ts index 499313fce..76a8c73d6 100644 --- a/packages/compat/src/audit.ts +++ b/packages/compat/src/audit.ts @@ -1,18 +1,22 @@ import { readFileSync, readJSONSync } from 'fs-extra'; -import { dirname, join, resolve as resolvePath } from 'path'; +import { join, resolve as resolvePath, dirname } from 'path'; import type { AppMeta, ResolverOptions } from '@embroider/core'; import { explicitRelative, hbsToJS, locateEmbroiderWorkingDir, Resolver, RewrittenPackageCache } from '@embroider/core'; import { Memoize } from 'typescript-memoize'; import chalk from 'chalk'; -import jsdom from 'jsdom'; import groupBy from 'lodash/groupBy'; -import fromPairs from 'lodash/fromPairs'; -import type { ExportAll, InternalImport, NamespaceMarker } from './audit/babel-visitor'; -import { auditJS, CodeFrameStorage, isNamespaceMarker } from './audit/babel-visitor'; +import type { NamespaceMarker } from './audit/babel-visitor'; +import { CodeFrameStorage, isNamespaceMarker } from './audit/babel-visitor'; import { AuditBuildOptions, AuditOptions } from './audit/options'; import { buildApp, BuildError, isBuildError } from './audit/build'; - -const { JSDOM } = jsdom; +import { + type ContentType, + type Module, + visitModules, + type RootMarker, + isRootMarker, + type CompleteModule, +} from './module-visitor'; export interface AuditMessage { message: string; @@ -36,112 +40,19 @@ export interface Finding { codeFrame?: string; } -export interface Module { - appRelativePath: string; - consumedFrom: (string | RootMarker)[]; - imports: Import[]; - exports: string[]; - resolutions: { [source: string]: string | null }; - content: string; -} - -interface ResolutionFailure { - isResolutionFailure: true; -} - -function isResolutionFailure(result: string | ResolutionFailure | undefined): result is ResolutionFailure { - return typeof result === 'object' && 'isResolutionFailure' in result; -} - -interface InternalModule { - consumedFrom: (string | RootMarker)[]; - - parsed?: { - imports: InternalImport[]; - exports: Set; - isCJS: boolean; - isAMD: boolean; - dependencies: string[]; - transpiledContent: string | Buffer; - }; - - resolved?: Map; - - linked?: { - exports: Set; - }; -} - -type ParsedInternalModule = Omit & { - parsed: NonNullable; -}; - -type ResolvedInternalModule = Omit & { - resolved: NonNullable; -}; - -function isResolved(module: InternalModule | undefined): module is ResolvedInternalModule { - return Boolean(module?.parsed && module.resolved); -} - -type LinkedInternalModule = Omit & { - linked: NonNullable; -}; - -function isLinked(module: InternalModule | undefined): module is LinkedInternalModule { - return Boolean(module?.parsed && module.resolved && module.linked); -} - -export interface Import { - source: string; - specifiers: { - name: string | NamespaceMarker; - local: string | null; // can be null when re-exporting, because in that case we import `name` from `source` but don't create any local binding for it - }[]; -} - export class AuditResults { modules: { [file: string]: Module } = {}; findings: Finding[] = []; - static create(baseDir: string, findings: Finding[], modules: Map) { + static create(baseDir: string, findings: Finding[], modules: Record) { let results = new this(); - for (let [filename, module] of modules) { - let publicModule: Module = { - appRelativePath: explicitRelative(baseDir, filename), - consumedFrom: module.consumedFrom.map(entry => { - if (isRootMarker(entry)) { - return entry; - } else { - return explicitRelative(baseDir, entry); - } - }), - resolutions: module.resolved - ? fromPairs( - [...module.resolved].map(([source, target]) => [ - source, - isResolutionFailure(target) ? null : explicitRelative(baseDir, target), - ]) - ) - : {}, - imports: module.parsed?.imports - ? module.parsed.imports.map(i => ({ - source: i.source, - specifiers: i.specifiers.map(s => ({ - name: s.name, - local: s.local, - })), - })) - : [], - exports: module.linked?.exports ? [...module.linked.exports] : [], - content: module.parsed?.transpiledContent - ? module.parsed?.transpiledContent.toString() - : 'module failed to transpile', - }; - results.modules[explicitRelative(baseDir, filename)] = publicModule; - } + results.modules = modules; for (let finding of findings) { - let relFinding = Object.assign({}, finding, { filename: explicitRelative(baseDir, finding.filename) }); + const filename = finding.filename.startsWith('./') + ? finding.filename + : explicitRelative(baseDir, finding.filename); + + let relFinding = Object.assign({}, finding, { filename }); results.findings.push(relFinding); } return results; @@ -200,9 +111,7 @@ export class AuditResults { } export class Audit { - private modules: Map = new Map(); private virtualModules: Map = new Map(); - private moduleQueue = new Set(); private findings = [] as Finding[]; private frames = new CodeFrameStorage(); @@ -265,74 +174,77 @@ export class Audit { } } - private visitorFor( - filename: string - ): ( - this: Audit, - filename: string, - content: Buffer | string - ) => Promise | Finding[]> { - if (filename.endsWith('.html')) { - return this.visitHTML; - } else if (filename.endsWith('.hbs')) { - return this.visitHBS; - } else if (filename.endsWith('.json')) { - return this.visitJSON; - } else { - return this.visitJS; - } - } - - private async drainQueue() { - while (this.moduleQueue.size > 0) { - let filename = this.moduleQueue.values().next().value as string; - this.moduleQueue.delete(filename); - this.debug('visit', filename); - let visitor = this.visitorFor(filename); - let content: string | Buffer; - if (this.virtualModules.has(filename)) { - content = this.virtualModules.get(filename)!; - } else { - content = readFileSync(filename); - } - // cast is safe because the only way to get into the queue is to go - // through scheduleVisit, and scheduleVisit creates the entry in - // this.modules. - let module: InternalModule = this.modules.get(filename)!; - let visitResult = await visitor.call(this, filename, content); - if (Array.isArray(visitResult)) { - // the visitor was unable to figure out the ParseFields and returned - // some number of Findings to us to explain why. - for (let finding of visitResult) { - this.pushFinding(finding); - } - } else { - module.parsed = visitResult; - module.resolved = await this.resolveDeps(visitResult.dependencies, filename); - } - } - } - async run(): Promise { (globalThis as any).embroider_audit = this.handleResolverError.bind(this); try { this.debug(`meta`, this.meta); - for (let asset of this.meta.assets) { - if (asset.endsWith('.html')) { - this.scheduleVisit(resolvePath(this.movedAppRoot, asset), { isRoot: true }); - } - } - await this.drainQueue(); - this.linkModules(); - this.inspectModules(); + let entrypoints = this.meta.assets.filter(a => a.endsWith('html')).map(a => resolvePath(this.movedAppRoot, a)); + + let modules = await visitModules({ + base: this.originAppRoot, + entrypoints, + resolveId: this.resolveId, + load: this.load, + findings: this.findings, + frames: this.frames, + babelConfig: this.babelConfig, + }); - return AuditResults.create(this.originAppRoot, this.findings, this.modules); + this.inspectModules(modules); + + return AuditResults.create(this.originAppRoot, this.findings, modules); } finally { delete (globalThis as any).embroider_audit; } } + private resolveId = async (specifier: string, fromFile: string): Promise => { + if (['@embroider/macros'].includes(specifier)) { + // the audit process deliberately removes the @embroider/macros babel + // plugins, so the imports are still present and should be left alone. + return undefined; + } + + if (fromFile.endsWith('.html') && specifier.startsWith(this.meta['root-url'])) { + // root-relative URLs in HTML are actually relative to the appDir + specifier = explicitRelative( + dirname(fromFile), + resolvePath(this.movedAppRoot, specifier.replace(this.meta['root-url'], '')) + ); + } + + let resolution = await this.resolver.nodeResolve(specifier, fromFile); + switch (resolution.type) { + case 'virtual': + this.virtualModules.set(resolution.filename, resolution.content); + return resolution.filename; + case 'not_found': + return undefined; + case 'real': + return resolution.filename; + } + }; + + private load = async (id: string): Promise<{ content: string | Buffer; type: ContentType } | undefined> => { + let content: string | Buffer; + if (this.virtualModules.has(id)) { + content = this.virtualModules.get(id)!; + } else { + content = readFileSync(id); + } + + if (id.endsWith('.html')) { + return { content, type: 'html' }; + } else if (id.endsWith('.hbs')) { + return { content: hbsToJS(content.toString('utf8')), type: 'javascript' }; + } else if (id.endsWith('.json')) { + return this.handleJSON(id, content); + } else { + return { content, type: 'javascript' }; + } + }; + private handleResolverError(msg: AuditMessage) { this.pushFinding({ message: msg.message, @@ -342,55 +254,23 @@ export class Audit { }); } - private linkModules() { - for (let module of this.modules.values()) { - if (isResolved(module)) { - this.linkModule(module); - } - } - } - - private linkModule(module: ResolvedInternalModule) { - let exports = new Set(); - for (let exp of module.parsed.exports) { - if (typeof exp === 'string') { - exports.add(exp); - } else { - let moduleName = module.resolved.get(exp.all)!; - if (!isResolutionFailure(moduleName)) { - let target = this.modules.get(moduleName)!; - if (!isLinked(target) && isResolved(target)) { - this.linkModule(target); - } - if (isLinked(target)) { - for (let innerExp of target.linked.exports) { - exports.add(innerExp); - } - } else { - // our module doesn't successfully enter linked state because it - // depends on stuff that also couldn't - return; - } - } + private inspectModules(modules: Record) { + for (let [filename, module] of Object.entries(modules)) { + if (module.type === 'complete') { + this.inspectImports(filename, module, modules); } } - module.linked = { - exports, - }; } - private inspectModules() { - for (let [filename, module] of this.modules) { - if (isLinked(module)) { - this.inspectImports(filename, module); + private inspectImports(filename: string, module: CompleteModule, modules: Record) { + for (let imp of module.imports) { + // our Audit should always ignore any imports of @embroider/macros because we already ignored them + // in resolveId above + if (imp.source === '@embroider/macros') { + continue; } - } - } - - private inspectImports(filename: string, module: LinkedInternalModule) { - for (let imp of module.parsed.imports) { - let resolved = module.resolved.get(imp.source); - if (isResolutionFailure(resolved)) { + let resolved = module.resolutions[imp.source]; + if (!resolved) { this.findings.push({ filename, message: 'unable to resolve dependency', @@ -398,9 +278,9 @@ export class Audit { codeFrame: this.frames.render(imp.codeFrameIndex), }); } else if (resolved) { - let target = this.modules.get(resolved)!; + let target = modules[resolved]!; for (let specifier of imp.specifiers) { - if (isLinked(target) && !this.moduleProvidesName(target, specifier.name)) { + if (target.type === 'complete' && !this.moduleProvidesName(target, specifier.name)) { if (specifier.name === 'default') { let backtick = '`'; this.findings.push({ @@ -423,159 +303,32 @@ export class Audit { } } - private moduleProvidesName(target: LinkedInternalModule, name: string | NamespaceMarker) { + private moduleProvidesName(target: CompleteModule, name: string | NamespaceMarker) { // any module can provide a namespace. // CJS and AMD are too dynamic to be sure exactly what names are available, // so they always get a pass - return isNamespaceMarker(name) || target.parsed.isCJS || target.parsed.isAMD || target.linked.exports.has(name); - } - - private async visitHTML(filename: string, content: Buffer | string): Promise { - let dom = new JSDOM(content); - let scripts = dom.window.document.querySelectorAll('script[type="module"]') as NodeListOf; - let dependencies = [] as string[]; - for (let script of scripts) { - let src = script.src; - if (!src) { - continue; - } - if (new URL(src, 'http://example.com:4321').origin !== 'http://example.com:4321') { - // src was absolute, we don't handle it - continue; - } - if (src.startsWith(this.meta['root-url'])) { - // root-relative URLs are actually relative to the appDir - src = explicitRelative( - dirname(filename), - resolvePath(this.movedAppRoot, src.replace(this.meta['root-url'], '')) - ); - } - dependencies.push(src); - } - - return { - imports: [], - exports: new Set(), - isCJS: false, - isAMD: false, - dependencies, - transpiledContent: content, - }; - } - - private async visitJS( - filename: string, - content: Buffer | string - ): Promise { - let rawSource = content.toString('utf8'); - try { - let result = auditJS(rawSource, filename, this.babelConfig, this.frames); - - for (let problem of result.problems) { - this.pushFinding({ - filename, - message: problem.message, - detail: problem.detail, - codeFrame: this.frames.render(problem.codeFrameIndex), - }); - } - return { - exports: result.exports, - imports: result.imports, - isCJS: result.isCJS, - isAMD: result.isAMD, - dependencies: result.imports.map(i => i.source), - transpiledContent: result.transpiledContent, - }; - } catch (err) { - if (['BABEL_PARSE_ERROR', 'BABEL_TRANSFORM_ERROR'].includes(err.code)) { - return [ - { - filename, - message: `failed to parse`, - detail: err.toString().replace(filename, explicitRelative(this.originAppRoot, filename)), - }, - ]; - } else { - throw err; - } - } + return isNamespaceMarker(name) || target.isCJS || target.isAMD || target.exports.includes(name); } - private async visitHBS( - filename: string, - content: Buffer | string - ): Promise { - let rawSource = content.toString('utf8'); - let js = hbsToJS(rawSource); - return this.visitJS(filename, js); - } - - private async visitJSON( - filename: string, - content: Buffer | string - ): Promise { + private handleJSON(filename: string, content: Buffer | string): { content: string; type: ContentType } | undefined { let js; try { let structure = JSON.parse(content.toString('utf8')); js = `export default ${JSON.stringify(structure)}`; } catch (err) { - return [ - { - filename, - message: `failed to parse JSON`, - detail: err.toString().replace(filename, explicitRelative(this.originAppRoot, filename)), - }, - ]; + this.findings.push({ + filename, + message: `failed to parse JSON`, + detail: err.toString().replace(filename, explicitRelative(this.originAppRoot, filename)), + }); + return; } - return this.visitJS(filename, js); - } - - private async resolveDeps(deps: string[], fromFile: string): Promise { - let resolved = new Map() as NonNullable; - for (let dep of deps) { - if (['@embroider/macros'].includes(dep)) { - // the audit process deliberately removes the @embroider/macros babel - // plugins, so the imports are still present and should be left alone. - continue; - } - - let resolution = await this.resolver.nodeResolve(dep, fromFile); - switch (resolution.type) { - case 'virtual': - this.virtualModules.set(resolution.filename, resolution.content); - resolved.set(dep, resolution.filename); - this.scheduleVisit(resolution.filename, fromFile); - break; - case 'not_found': - resolved.set(dep, { isResolutionFailure: true as true }); - break; - case 'real': - resolved.set(dep, resolution.filename); - this.scheduleVisit(resolution.filename, fromFile); - break; - } - } - return resolved; + return { content: js, type: 'javascript' }; } private pushFinding(finding: Finding) { this.findings.push(finding); } - - private scheduleVisit(filename: string, parent: string | RootMarker) { - let record = this.modules.get(filename); - if (!record) { - this.debug(`discovered`, filename); - record = { - consumedFrom: [parent], - }; - this.modules.set(filename, record); - this.moduleQueue.add(filename); - } else { - record.consumedFrom.push(parent); - } - } } function isMacrosPlugin(p: any) { @@ -595,10 +348,4 @@ function indent(str: string, level: number) { .join('\n'); } -export interface RootMarker { - isRoot: true; -} - -export function isRootMarker(value: string | RootMarker | undefined): value is RootMarker { - return Boolean(value && typeof value !== 'string' && value.isRoot); -} +export { Module }; diff --git a/packages/compat/src/module-visitor.ts b/packages/compat/src/module-visitor.ts new file mode 100644 index 000000000..e1da5b15a --- /dev/null +++ b/packages/compat/src/module-visitor.ts @@ -0,0 +1,407 @@ +import { explicitRelative } from '@embroider/core'; +import { + type CodeFrameStorage, + auditJS, + type ExportAll, + type InternalImport, + type NamespaceMarker, +} from './audit/babel-visitor'; +import fromPairs from 'lodash/fromPairs'; +import assertNever from 'assert-never'; +import { JSDOM } from 'jsdom'; + +import type { Finding } from './audit'; +import type { TransformOptions } from '@babel/core'; + +export type Module = CompleteModule | ParsedModule | UnparseableModule; + +export interface UnparseableModule { + type: 'unparseable'; + appRelativePath: string; + consumedFrom: (string | RootMarker)[]; +} + +export interface ParsedModule extends Omit { + type: 'parsed'; + imports: Import[]; + resolutions: { [source: string]: string | null }; + content: string; + isCJS: boolean; + isAMD: boolean; +} + +export interface CompleteModule extends Omit { + type: 'complete'; + exports: string[]; +} + +interface InternalModule { + consumedFrom: (string | RootMarker)[]; + + parsed?: { + imports: InternalImport[]; + exports: Set; + isCJS: boolean; + isAMD: boolean; + dependencies: string[]; + transpiledContent: string | Buffer; + }; + + resolved?: Map; + + linked?: { + exports: Set; + }; +} + +type ParsedInternalModule = Omit & { + parsed: NonNullable; +}; + +type ResolvedInternalModule = Omit & { + resolved: NonNullable; +}; + +function isResolved(module: InternalModule | undefined): module is ResolvedInternalModule { + return Boolean(module?.parsed && module.resolved); +} + +type LinkedInternalModule = Omit & { + linked: NonNullable; +}; + +function isLinked(module: InternalModule | undefined): module is LinkedInternalModule { + return Boolean(module?.parsed && module.resolved && module.linked); +} + +export interface Import { + source: string; + specifiers: { + name: string | NamespaceMarker; + local: string | null; // can be null when re-exporting, because in that case we import `name` from `source` but don't create any local binding for it + codeFrameIndex: number | undefined; + }[]; + codeFrameIndex: number | undefined; +} + +interface VisitorParams { + base: string; + resolveId: (specifier: string, fromFile: string) => Promise; + load: (id: string) => Promise<{ content: string | Buffer; type: ContentType } | undefined>; + entrypoints: string[]; + debug?: boolean; + + findings: Finding[]; + frames: CodeFrameStorage; + babelConfig: TransformOptions; +} + +export async function visitModules(params: VisitorParams): Promise> { + let visitor = new ModuleVisitor(params); + return await visitor.run(); +} + +export type ContentType = 'javascript' | 'html'; + +class ModuleVisitor { + private modules: Map = new Map(); + + private moduleQueue = new Set(); + private base: string; + private debugEnabled: boolean; + private resolveId: (specifier: string, fromFile: string) => Promise; + private load: (id: string) => Promise<{ content: string | Buffer; type: ContentType } | undefined>; + private entrypoints: string[]; + + constructor(private params: VisitorParams) { + this.base = params.base; + this.debugEnabled = Boolean(params.debug); + this.resolveId = params.resolveId; + this.load = params.load; + this.entrypoints = params.entrypoints; + } + + async run(): Promise> { + for (let entry of this.entrypoints) { + this.scheduleVisit(entry, { isRoot: true }); + } + await this.drainQueue(); + this.linkModules(); + return this.buildResults(); + } + + private async drainQueue() { + while (this.moduleQueue.size > 0) { + let id = this.moduleQueue.values().next().value as string; + this.moduleQueue.delete(id); + this.debug('visit', id); + let loaded = await this.load(id); + if (Array.isArray(loaded)) { + for (let finding of loaded) { + this.params.findings.push(finding); + } + continue; + } + // if the load hook returned undefined we need to just skip it + if (loaded === undefined) { + continue; + } + let { content, type } = loaded; + + let visitor = this.visitorFor(type); + + // cast is safe because the only way to get into the queue is to go + // through scheduleVisit, and scheduleVisit creates the entry in + // this.modules. + let module: InternalModule = this.modules.get(id)!; + let visitResult = await visitor.call(this, id, content); + if (Array.isArray(visitResult)) { + // the visitor was unable to figure out the ParseFields and returned + // some number of Findings to us to explain why. + for (let finding of visitResult) { + this.params.findings.push(finding); + } + } else { + module.parsed = visitResult; + module.resolved = await this.resolveDeps(visitResult.dependencies, id); + } + } + } + + private linkModules() { + for (let module of this.modules.values()) { + if (isResolved(module)) { + this.linkModule(module); + } + } + } + + private linkModule(module: ResolvedInternalModule) { + let exports = new Set(); + for (let exp of module.parsed.exports) { + if (typeof exp === 'string') { + exports.add(exp); + } else { + let moduleName = module.resolved.get(exp.all)!; + if (!isResolutionFailure(moduleName)) { + let target = this.modules.get(moduleName)!; + if (!isLinked(target) && isResolved(target)) { + this.linkModule(target); + } + if (isLinked(target)) { + for (let innerExp of target.linked.exports) { + exports.add(innerExp); + } + } else { + // our module doesn't successfully enter linked state because it + // depends on stuff that also couldn't + return; + } + } + } + } + module.linked = { + exports, + }; + } + + private async resolveDeps(deps: string[], fromFile: string): Promise { + let resolved = new Map() as NonNullable; + for (let dep of deps) { + if (['@embroider/macros'].includes(dep)) { + // the audit process deliberately removes the @embroider/macros babel + // plugins, so the imports are still present and should be left alone. + continue; + } + + let resolution = await this.resolveId(dep, fromFile); + if (resolution) { + resolved.set(dep, resolution); + this.scheduleVisit(resolution, fromFile); + continue; + } else { + resolved.set(dep, { isResolutionFailure: true as true }); + continue; + } + } + return resolved; + } + + private scheduleVisit(id: string, parent: string | RootMarker) { + let record = this.modules.get(id); + if (!record) { + this.debug(`discovered`, id); + record = { + consumedFrom: [parent], + }; + this.modules.set(id, record); + this.moduleQueue.add(id); + } else { + record.consumedFrom.push(parent); + } + } + + private visitorFor( + type: ContentType + ): ( + this: ModuleVisitor, + filename: string, + content: Buffer | string + ) => Promise> { + switch (type) { + case 'html': + return this.visitHTML; + case 'javascript': + return this.visitJS; + default: + throw assertNever(type); + } + } + + private async visitHTML(_filename: string, content: Buffer | string): Promise { + let dom = new JSDOM(content); + let scripts = dom.window.document.querySelectorAll('script[type="module"]') as NodeListOf; + let dependencies = [] as string[]; + for (let script of scripts) { + let src = script.src; + if (!src) { + continue; + } + if (new URL(src, 'http://example.com:4321').origin !== 'http://example.com:4321') { + // src was absolute, we don't handle it + continue; + } + dependencies.push(src); + } + + return { + imports: [], + exports: new Set(), + isCJS: false, + isAMD: false, + dependencies, + transpiledContent: content, + }; + } + + private async visitJS( + filename: string, + content: Buffer | string + ): Promise { + let rawSource = content.toString('utf8'); + try { + let result = auditJS(rawSource, filename, this.params.babelConfig, this.params.frames); + + for (let problem of result.problems) { + this.params.findings.push({ + filename, + message: problem.message, + detail: problem.detail, + codeFrame: this.params.frames.render(problem.codeFrameIndex), + }); + } + return { + exports: result.exports, + imports: result.imports, + isCJS: result.isCJS, + isAMD: result.isAMD, + dependencies: result.imports.map(i => i.source), + transpiledContent: result.transpiledContent, + }; + } catch (err) { + if (['BABEL_PARSE_ERROR', 'BABEL_TRANSFORM_ERROR'].includes(err.code)) { + return [ + { + filename, + message: `failed to parse`, + detail: err.toString().replace(filename, explicitRelative(this.base, filename)), + }, + ]; + } else { + throw err; + } + } + } + + private debug(message: string, ...args: any[]) { + if (this.debugEnabled) { + console.log(message, ...args); + } + } + + private toPublicModule(filename: string, module: InternalModule): Module { + let result: UnparseableModule = { + type: 'unparseable', + appRelativePath: explicitRelative(this.base, filename), + consumedFrom: module.consumedFrom.map(entry => { + if (isRootMarker(entry)) { + return entry; + } else { + return explicitRelative(this.base, entry); + } + }), + }; + + if (!module.parsed || !module.resolved) { + return result; + } + + let parsedResult: ParsedModule = { + ...result, + type: 'parsed', + resolutions: fromPairs( + [...module.resolved].map(([source, target]) => [ + source, + isResolutionFailure(target) ? null : explicitRelative(this.base, target), + ]) + ), + imports: module.parsed.imports.map(i => ({ + source: i.source, + specifiers: i.specifiers.map(s => ({ + name: s.name, + local: s.local, + codeFrameIndex: s.codeFrameIndex, + })), + codeFrameIndex: i.codeFrameIndex, + })), + content: module.parsed.transpiledContent.toString(), + isAMD: Boolean(module.parsed.isAMD), + isCJS: Boolean(module.parsed.isCJS), + }; + + if (!module.linked) { + return parsedResult; + } + + return { + ...parsedResult, + type: 'complete', + exports: [...module.linked.exports], + }; + } + + private buildResults() { + let publicModules: Record = {}; + for (let [filename, module] of this.modules) { + let publicModule = this.toPublicModule(filename, module); + publicModules[explicitRelative(this.base, filename)] = publicModule; + } + return publicModules; + } +} + +export interface RootMarker { + isRoot: true; +} + +export function isRootMarker(value: string | RootMarker | undefined): value is RootMarker { + return Boolean(value && typeof value !== 'string' && value.isRoot); +} + +interface ResolutionFailure { + isResolutionFailure: true; +} + +function isResolutionFailure(result: string | ResolutionFailure | undefined): result is ResolutionFailure { + return typeof result === 'object' && 'isResolutionFailure' in result; +} diff --git a/packages/compat/tests/audit.test.ts b/packages/compat/tests/audit.test.ts index 478c7b7b2..e96d1ebb4 100644 --- a/packages/compat/tests/audit.test.ts +++ b/packages/compat/tests/audit.test.ts @@ -4,7 +4,6 @@ import type { AppMeta } from '@embroider/core'; import { throwOnWarnings } from '@embroider/core'; import merge from 'lodash/merge'; import fromPairs from 'lodash/fromPairs'; -import type { Finding } from '../src/audit'; import { Audit } from '../src/audit'; import type { CompatResolverOptions } from '../src/resolver-transform'; import type { TransformOptions } from '@babel/core'; @@ -223,7 +222,11 @@ describe('audit', function () { }); let result = await audit(); expect(result.findings).toEqual([]); - let exports = result.modules['./app.js'].exports; + let appModule = result.modules['./app.js']; + if (appModule.type !== 'complete') { + throw new Error('./app.js module did not parse or resolve correctly'); + } + let exports = appModule.exports; expect(exports).toContain('a'); expect(exports).toContain('b'); expect(exports).toContain('c'); @@ -270,7 +273,11 @@ describe('audit', function () { let result = await audit(); expect(result.findings).toEqual([]); - let exports = result.modules['./app.js'].exports; + let appModule = result.modules['./app.js']; + if (appModule.type !== 'complete') { + throw new Error('./app.js module did not parse or resolve correctly'); + } + let exports = appModule.exports; expect(exports).toContain('a'); expect(exports).toContain('b'); expect(exports).not.toContain('thing'); @@ -278,9 +285,9 @@ describe('audit', function () { expect(exports).toContain('alpha'); expect(exports).toContain('beta'); expect(exports).toContain('libC'); - expect(result.modules['./app.js'].imports.length).toBe(3); - let imports = fromPairs(result.modules['./app.js'].imports.map(imp => [imp.source, imp.specifiers])); - expect(imports).toEqual({ + expect(appModule.imports.length).toBe(3); + let imports = fromPairs(appModule.imports.map(imp => [imp.source, imp.specifiers])); + expect(withoutCodeFrames(imports)).toEqual({ './lib-a': [ { name: 'default', local: null }, { name: 'b', local: null }, @@ -397,10 +404,25 @@ describe('audit', function () { }); }); -function withoutCodeFrames(findings: Finding[]): Finding[] { - return findings.map(f => { - let result = Object.assign({}, f); - delete result.codeFrame; - return result; - }); +function withoutCodeFrames(modules: Record): Record; +function withoutCodeFrames(findings: T[]): T[]; +function withoutCodeFrames( + input: Record | U[] +): Record | U[] { + if (Array.isArray(input)) { + return input.map(f => { + let result = Object.assign({}, f); + delete result.codeFrame; + return result; + }); + } + const result: Record = {}; + + const knownInput = input; + + for (let item in knownInput) { + result[item] = knownInput[item].map(i => ({ ...i, codeFrameIndex: undefined })); + } + + return result; } diff --git a/test-packages/support/audit-assertions.ts b/test-packages/support/audit-assertions.ts index 0c1a66415..af421e07f 100644 --- a/test-packages/support/audit-assertions.ts +++ b/test-packages/support/audit-assertions.ts @@ -114,6 +114,10 @@ export class ExpectModule { this.emitMissingModule(); return; } + if (this.module.type === 'unparseable') { + this.emitUnparsableModule(message); + return; + } const result = fn(this.module.content); this.expectAudit.assert.pushResult({ result, @@ -123,11 +127,24 @@ export class ExpectModule { }); } + private emitUnparsableModule(message?: string) { + this.expectAudit.assert.pushResult({ + result: false, + actual: `${this.inputName} failed to parse`, + expected: true, + message: `${this.inputName} failed to parse${message ? `: (${message})` : ''}`, + }); + } + codeEquals(expectedSource: string) { if (!this.module) { this.emitMissingModule(); return; } + if (this.module.type === 'unparseable') { + this.emitUnparsableModule(); + return; + } this.expectAudit.assert.codeEqual(this.module.content, expectedSource); } @@ -136,6 +153,10 @@ export class ExpectModule { this.emitMissingModule(); return; } + if (this.module.type === 'unparseable') { + this.emitUnparsableModule(); + return; + } this.expectAudit.assert.codeContains(this.module.content, expectedSource); } @@ -144,6 +165,12 @@ export class ExpectModule { this.emitMissingModule(); return new EmptyExpectResolution(); } + + if (this.module.type === 'unparseable') { + this.emitUnparsableModule(); + return new EmptyExpectResolution(); + } + if (!(specifier in this.module.resolutions)) { this.expectAudit.assert.pushResult({ result: false, diff --git a/tests/scenarios/compat-exclude-dot-files-test.ts b/tests/scenarios/compat-exclude-dot-files-test.ts index afa37a03f..db48e4121 100644 --- a/tests/scenarios/compat-exclude-dot-files-test.ts +++ b/tests/scenarios/compat-exclude-dot-files-test.ts @@ -75,7 +75,7 @@ appScenarios // but not be picked up in the entrypoint expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/app-template.js') + .resolves('/assets/app-template.js') .toModule() .withContents(content => { assert.notOk(/app-template\/\.foobar/.test(content), '.foobar is not in the entrypoint'); diff --git a/tests/scenarios/compat-renaming-test.ts b/tests/scenarios/compat-renaming-test.ts index 1d7b48a5f..03aa3ae3c 100644 --- a/tests/scenarios/compat-renaming-test.ts +++ b/tests/scenarios/compat-renaming-test.ts @@ -256,7 +256,7 @@ appScenarios test('renamed modules keep their classic runtime name when used as implicit-modules', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/app-template.js') + .resolves('/assets/app-template.js') .toModule() .resolves('./-embroider-implicit-modules.js') .toModule() diff --git a/tests/scenarios/compat-stage2-test.ts b/tests/scenarios/compat-stage2-test.ts index a1496ab9e..a50fcb657 100644 --- a/tests/scenarios/compat-stage2-test.ts +++ b/tests/scenarios/compat-stage2-test.ts @@ -132,7 +132,7 @@ stage2Scenarios // check that the app trees with in repo addon are combined correctly expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() //TODO investigate removing this @embroider-dep .resolves('my-app/service/in-repo.js') @@ -143,7 +143,7 @@ stage2Scenarios // secondary in-repo-addon was correctly detected and activated expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() //TODO investigate removing this @embroider-dep .resolves('my-app/services/secondary.js') @@ -210,7 +210,7 @@ stage2Scenarios test('verifies that the correct lexigraphically sorted addons win', function () { let expectModule = expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule(); expectModule.resolves('my-app/service/in-repo.js').to('./lib/in-repo-b/_app_/service/in-repo.js'); expectModule.resolves('my-app/service/addon.js').to('./node_modules/dep-b/_app_/service/addon.js'); @@ -220,7 +220,7 @@ stage2Scenarios test('addons declared as dependencies should win over devDependencies', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .resolves('my-app/service/dep-wins-over-dev.js') .to('./node_modules/dep-b/_app_/service/dep-wins-over-dev.js'); @@ -229,7 +229,7 @@ stage2Scenarios test('in repo addons declared win over dependencies', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .resolves('my-app/service/in-repo-over-deps.js') .to('./lib/in-repo-a/_app_/service/in-repo-over-deps.js'); @@ -238,7 +238,7 @@ stage2Scenarios test('ordering with before specified', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .resolves('my-app/service/test-before.js') .to('./node_modules/dev-d/_app_/service/test-before.js'); @@ -247,7 +247,7 @@ stage2Scenarios test('ordering with after specified', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .resolves('my-app/service/test-after.js') .to('./node_modules/dev-b/_app_/service/test-after.js'); @@ -709,7 +709,7 @@ stage2Scenarios test('non-static other paths are included in the entrypoint', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule().codeContains(`d("my-app/non-static-dir/another-library", function () { return i("my-app/non-static-dir/another-library.js"); });`); @@ -718,7 +718,7 @@ stage2Scenarios test('static other paths are not included in the entrypoint', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .withContents(content => { return !/my-app\/static-dir\/my-library\.js"/.test(content); @@ -728,7 +728,7 @@ stage2Scenarios test('top-level static other paths are not included in the entrypoint', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .withContents(content => { return !content.includes('my-app/top-level-static.js'); @@ -738,7 +738,7 @@ stage2Scenarios test('staticAppPaths do not match partial path segments', function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule() .withContents(content => { return content.includes('my-app/static-dir-not-really/something.js'); diff --git a/tests/scenarios/compat-template-colocation-test.ts b/tests/scenarios/compat-template-colocation-test.ts index 6055b4815..5efba6c18 100644 --- a/tests/scenarios/compat-template-colocation-test.ts +++ b/tests/scenarios/compat-template-colocation-test.ts @@ -133,7 +133,7 @@ scenarios test(`app's colocated components are implicitly included correctly`, function () { expectAudit .module('./node_modules/.embroider/rewritten-app/index.html') - .resolves('./assets/my-app.js') + .resolves('/assets/my-app.js') .toModule().codeContains(`d("my-app/components/has-colocated-template", function () { return i("my-app/components/has-colocated-template.js"); });`);