Skip to content

Commit

Permalink
feat(compartment-mapper): Reenable CommonJS
Browse files Browse the repository at this point in the history
This reverts commit 8d7fb04.
  • Loading branch information
kriskowal committed Apr 22, 2021
1 parent 9800a3f commit e76d95e
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 11 deletions.
3 changes: 0 additions & 3 deletions packages/compartment-mapper/README.md
@@ -1,8 +1,5 @@
# Compartment mapper

> :warning: CommonJS support is temporarily disabled, pending a solution for
> heuristic static analysis that does not entrain any Node.js built-ins.
The compartment mapper builds _compartment maps_ for Node.js style
applications, finding their dependencies and describing how to create
[Compartments][] for each package in the application.
Expand Down
2 changes: 2 additions & 0 deletions packages/compartment-mapper/package.json
Expand Up @@ -25,6 +25,8 @@
"test": "ava"
},
"dependencies": {
"@babel/parser": "^7.8.4",
"@babel/traverse": "^7.8.4",
"ses": "^0.12.7"
},
"devDependencies": {
Expand Down
10 changes: 5 additions & 5 deletions packages/compartment-mapper/src/node-modules.js
Expand Up @@ -135,9 +135,9 @@ const findPackage = async (readDescriptor, directory, name) => {
}
};

const languages = ['mjs', 'json'];
const uncontroversialParsers = { mjs: 'mjs', json: 'json' };
const commonParsers = uncontroversialParsers;
const languages = ['mjs', 'cjs', 'json'];
const uncontroversialParsers = { cjs: 'cjs', mjs: 'mjs', json: 'json' };
const commonParsers = { js: 'cjs', ...uncontroversialParsers };
const moduleParsers = { js: 'mjs', ...uncontroversialParsers };

/**
Expand All @@ -152,7 +152,7 @@ const inferParsers = (descriptor, location) => {
throw new Error(
`Cannot interpret parser map ${JSON.stringify(
parsers,
)} of package at ${location}, must be an object mapping file extensions to corresponding languages (mjs for ECMAScript modules or json for JSON modules`,
)} of package at ${location}, must be an object mapping file extensions to corresponding languages (mjs for ECMAScript modules, cjs for CommonJS modules, or json for JSON modules`,
);
}
const invalidLanguages = values(parsers).filter(
Expand All @@ -162,7 +162,7 @@ const inferParsers = (descriptor, location) => {
throw new Error(
`Cannot interpret parser map language values ${JSON.stringify(
invalidLanguages,
)} of package at ${location}, must be an object mapping file extensions to corresponding languages (mjs for ECMAScript modules or json for JSON modules`,
)} of package at ${location}, must be an object mapping file extensions to corresponding languages (mjs for ECMAScript modules, cjs for CommonJS modules, or json for JSON modules`,
);
}
return { ...uncontroversialParsers, ...parsers };
Expand Down
64 changes: 64 additions & 0 deletions packages/compartment-mapper/src/parse-requires.js
@@ -0,0 +1,64 @@
import parser from '@babel/parser';
import traverse from '@babel/traverse';

/* parseRequires
* Analyzes a CommonJS module's obvious static shallow dependencies, returning
* an array of every module specifier that the module requires with a string
* literal.
*
* Does not differentiate conditional dependencies, which will likely cause
* errors in the loading phase. For example, `if (isNode) { require('fs') }`
* will reveal an unconditional dependency on a possibly unloadable `fs`
* module, even though the module has a contingency when that module is not
* available.
*
* Does not discover dynamic dependencies, which will likely cause errors in
* the module execution phase.
* For example, `require(path)` will not be discovered by this parser, the
* module will successfully load, but will likely be unable to synchronously
* require the module with the given path.
*
* @typedef ImportSpecifier string
* @param {string} source
* @param {string} location
* @return {Array<ImportSpecifier>}
*/
export const parseRequires = (source, location, packageLocation) => {
try {
const ast = parser.parse(source);
const required = new Set();
traverse.default(ast, {
CallExpression(path) {
const { node, scope } = path;
const { callee, arguments: args } = node;
if (callee.name !== 'require') {
return;
}
// We do not recognize `require()` or `require("id", true)`.
if (args.length !== 1) {
return;
}
// We only consider the form `require("id")`, not `require(expression)`.
const [specifier] = args;
if (specifier.type !== 'StringLiteral') {
return;
}
// The existence of a require variable in any parent scope indicates that
// this is not a free-variable use of the term `require`, so it does not
// likely refer to the module's given `require`.
if (scope.hasBinding('require')) {
return;
}
required.add(specifier.value);
},
});
return Array.from(required).sort();
} catch (error) {
if (/import/.exec(error.message) !== null) {
throw new Error(
`Cannot parse CommonJS module at ${location}, consider adding "type": "module" to package.json in ${packageLocation}: ${error}`,
);
}
throw new Error(`Cannot parse CommonJS module at ${location}: ${error}`);
}
};
65 changes: 65 additions & 0 deletions packages/compartment-mapper/src/parse.js
@@ -1,6 +1,7 @@
// @ts-check
/// <reference types="ses" />

import { parseRequires } from './parse-requires.js';
import { parseExtension } from './extension.js';
import * as json from './json.js';

Expand Down Expand Up @@ -48,6 +49,7 @@ export const parseJson = async (
const source = textDecoder.decode(bytes);
/** @type {Readonly<Array<string>>} */
const imports = freeze([]);

/**
* @param {Object} exports
*/
Expand All @@ -61,9 +63,72 @@ export const parseJson = async (
};
};

/** @type {ParseFn} */
export const parseCjs = async (
bytes,
_specifier,
location,
packageLocation,
) => {
const source = textDecoder.decode(bytes);

if (typeof location !== 'string') {
throw new TypeError(
`Cannot create CommonJS static module record, module location must be a string, got ${location}`,
);
}

const imports = parseRequires(source, location, packageLocation);
/**
* @param {Object} exports
* @param {Compartment} compartment
* @param {Record<string, string>} resolvedImports
*/
const execute = async (exports, compartment, resolvedImports) => {
const functor = compartment.evaluate(
`(function (require, exports, module, __filename, __dirname) { ${source} //*/\n})\n//# sourceURL=${location}`,
);

let moduleExports = exports;

const module = freeze({
get exports() {
return moduleExports;
},
set exports(namespace) {
moduleExports = namespace;
exports.default = namespace;
},
});

const require = freeze(importSpecifier => {
const namespace = compartment.importNow(resolvedImports[importSpecifier]);
if (namespace.default !== undefined) {
return namespace.default;
}
return namespace;
});

functor(
require,
exports,
module,
location, // __filename
new URL('./', location).toString(), // __dirname
);
};

return {
parser: 'cjs',
bytes,
record: freeze({ imports, execute }),
};
};

/** @type {Record<string, ParseFn>} */
export const parserForLanguage = {
mjs: parseMjs,
cjs: parseCjs,
json: parseJson,
};

Expand Down
Binary file modified packages/compartment-mapper/test/app.agar
Binary file not shown.
8 changes: 8 additions & 0 deletions packages/compartment-mapper/test/node_modules/app/main.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion packages/compartment-mapper/test/test-main.js
Expand Up @@ -30,14 +30,20 @@ const assertFixture = (t, namespace) => {
avery,
brooke,
clarke,
danny,
builtin,
receivedGlobalProperty,
receivedGlobalLexical,
typecommon,
typemodule,
typehybrid,
typeparsers,
} = namespace;

t.is(avery, 'Avery', 'exports avery');
t.is(brooke, 'Brooke', 'exports brooke');
t.is(clarke, 'Clarke', 'exports clarke');
t.is(danny, 'Danny', 'exports danny');

t.is(builtin, 'builtin', 'exports builtin');

Expand All @@ -47,9 +53,25 @@ const assertFixture = (t, namespace) => {
globalLexicals.globalLexical,
'exports global lexical',
);
t.deepEqual(
typecommon,
[42, 42, 42, 42],
'type=common package carries exports',
);
t.deepEqual(
typemodule,
[42, 42, 42, 42],
'type=module package carries exports',
);
t.deepEqual(
typeparsers,
[42, 42, 42, 42],
'parsers-specifying package carries exports',
);
t.is(typehybrid, 42, 'type=module and module= package carries exports');
};

const fixtureAssertionCount = 6;
const fixtureAssertionCount = 11;

// The "create builtin" test prepares a builtin module namespace object that
// gets threaded into all subsequent tests to satisfy the "builtin" module
Expand Down
31 changes: 31 additions & 0 deletions packages/compartment-mapper/test/test-parse-requires.js
@@ -0,0 +1,31 @@
import test from 'ava';
import { parseRequires } from '../src/parse-requires.js';

test('parse unique require calls', t => {
t.plan(1);
const code = `
require("b"); // sorted later
// require("bogus"); // not discovered
function square(require) {
require("shadowed"); // over-shadowed by argument
}
require("a");
function c() {
require("c"); // found despite inner scope
}
require("a"); // de-duplicated
require( "d" ); // such space
require("bogus", "bogus", "bogus");
require("id\\""); // found, despite inner quote
`;
const requires = parseRequires(code);
t.deepEqual(requires, ['a', 'b', 'c', 'd', 'id"']);
});

0 comments on commit e76d95e

Please sign in to comment.