From 0371e25d6c2a0059b5096120724033b05437ac7a Mon Sep 17 00:00:00 2001 From: Kris Kowal Date: Mon, 19 Aug 2024 15:27:15 -0700 Subject: [PATCH] feat(evasive-transform): elideComments option --- packages/evasive-transform/NEWS.md | 6 + packages/evasive-transform/src/index.js | 17 +- .../evasive-transform/src/transform-ast.js | 10 +- .../src/transform-comment.js | 47 ++++- .../test/elide-comment.test.js | 168 ++++++++++++++++++ .../test/transform-comment.test.js | 22 +-- 6 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 packages/evasive-transform/test/elide-comment.test.js diff --git a/packages/evasive-transform/NEWS.md b/packages/evasive-transform/NEWS.md index e69de29bb2..cb1c1081c7 100644 --- a/packages/evasive-transform/NEWS.md +++ b/packages/evasive-transform/NEWS.md @@ -0,0 +1,6 @@ +User-visible changes in `@endo/evasive-transform`: + +# Next release + +- Adds an `elideComments` option to replace the interior of comments with + minimal blank space with identical cursor advancement behavior. diff --git a/packages/evasive-transform/src/index.js b/packages/evasive-transform/src/index.js index 01fb56a493..cb3cc0025e 100644 --- a/packages/evasive-transform/src/index.js +++ b/packages/evasive-transform/src/index.js @@ -19,6 +19,7 @@ import { generate } from './generate.js'; * @property {string|import('source-map').RawSourceMap} [sourceMap] - Original source map in JSON string or object form * @property {string} [sourceUrl] - URL or filepath of the original source in `code` * @property {boolean} [useLocationUnmap] - Enable location unmapping. Only applies if `sourceMap` was provided + * @property {boolean} [elideComments] - Replace comments with an ellipsis but preserve interior newlines. * @property {import('./parse-ast.js').SourceType} [sourceType] - Module source type * @public */ @@ -41,7 +42,13 @@ import { generate } from './generate.js'; export function evadeCensorSync(source, options) { // TODO Use options ?? {} when resolved: // https://github.com/Agoric/agoric-sdk/issues/8671 - const { sourceMap, sourceUrl, useLocationUnmap, sourceType } = options || {}; + const { + sourceMap, + sourceUrl, + useLocationUnmap, + sourceType, + elideComments = false, + } = options || {}; // Parse the rolled-up chunk with Babel. // We are prepared for different module systems. @@ -53,9 +60,13 @@ export function evadeCensorSync(source, options) { typeof sourceMap === 'string' ? sourceMap : JSON.stringify(sourceMap); if (sourceMap && useLocationUnmap) { - transformAst(ast, { sourceMap: sourceMapJson, useLocationUnmap }); + transformAst(ast, { + sourceMap: sourceMapJson, + useLocationUnmap, + elideComments, + }); } else { - transformAst(ast); + transformAst(ast, { elideComments }); } if (sourceUrl) { diff --git a/packages/evasive-transform/src/transform-ast.js b/packages/evasive-transform/src/transform-ast.js index 68bc689ece..de7aca4d32 100644 --- a/packages/evasive-transform/src/transform-ast.js +++ b/packages/evasive-transform/src/transform-ast.js @@ -5,7 +5,7 @@ */ import babelTraverse from '@babel/traverse'; -import { transformComment } from './transform-comment.js'; +import { evadeComment, elideComment } from './transform-comment.js'; import { makeLocationUnmapper } from './location-unmapper.js'; // TODO The following is sufficient on Node.js, but for compatibility with @@ -34,6 +34,7 @@ const traverse = /** @type {typeof import('@babel/traverse')['default']} */ ( * @typedef TransformAstOptionsWithoutSourceMap * @property {false} [useLocationUnmap] - Enable location unmapping * @property {string} [sourceMap] - Original source map + * @property {boolean} [elideComments] */ /** @@ -45,6 +46,7 @@ const traverse = /** @type {typeof import('@babel/traverse')['default']} */ ( * @typedef TransformAstOptionsWithLocationUnmap * @property {true} useLocationUnmap - Enable location unmapping * @property {string} sourceMap - Original source map + * @property {boolean} [elideComments] */ /** @@ -57,12 +59,16 @@ const traverse = /** @type {typeof import('@babel/traverse')['default']} */ ( * @param {TransformAstOptions} [opts] * @returns {void} */ -export function transformAst(ast, { sourceMap, useLocationUnmap } = {}) { +export function transformAst( + ast, + { sourceMap, useLocationUnmap, elideComments = false } = {}, +) { /** @type {import('./location-unmapper.js').LocationUnmapper|undefined} */ let unmapLoc; if (sourceMap && useLocationUnmap) { unmapLoc = makeLocationUnmapper(sourceMap, ast); } + const transformComment = elideComments ? elideComment : evadeComment; traverse(ast, { enter(p) { const { loc, leadingComments, innerComments, trailingComments, type } = diff --git a/packages/evasive-transform/src/transform-comment.js b/packages/evasive-transform/src/transform-comment.js index 99764b31ed..1053c78b5d 100644 --- a/packages/evasive-transform/src/transform-comment.js +++ b/packages/evasive-transform/src/transform-comment.js @@ -1,6 +1,6 @@ /** - * Provides {@link transformComment} which evades SES restrictions by modifying - * a Babel AST Node. + * Provides {@link evadeComment} and {@link elideComment} which evade SES + * restrictions by modifying a Babel AST Node. * * @module */ @@ -28,7 +28,7 @@ const HTML_COMMENT_END_RE = new RegExp(`--${'>'}`, 'g'); * @param {import('@babel/types').Comment} node * @param {import('./location-unmapper.js').LocationUnmapper} [unmapLoc] */ -export function transformComment(node, unmapLoc) { +export function evadeComment(node, unmapLoc) { node.type = 'CommentBlock'; // Within comments... node.value = node.value @@ -46,3 +46,44 @@ export function transformComment(node, unmapLoc) { unmapLoc(node.loc); } } + +/** + * Inspects a comment for a hint that it must be preserved by a transform. + * + * @param {string} comment + */ +const markedForPreservation = comment => { + if (comment.startsWith('!')) { + return true; + } + if (comment.startsWith('*')) { + // Detect jsdoc style @preserve, @copyright, @license, @cc_on (IE + // cconditional comments) + return /(?:^|\n)\s*\*?\s*@(?:preserve|copyright|license|cc_on)\b/.test( + comment, + ); + } + return false; +}; + +/** + * Elides all non-newlines before the last line and replaces all non-newlines + * with spaces on the last line. + * This can greatly reduce the size of a well-commented artifact without + * displacing lines or columns in the transformed code. + * + * @param {import('@babel/types').Comment} node + * @param {import('./location-unmapper.js').LocationUnmapper} [unmapLoc] + */ +export const elideComment = (node, unmapLoc) => { + if (node.type === 'CommentBlock') { + if (!markedForPreservation(node.value)) { + node.value = node.value.replace(/[^\n]+\n/g, '\n').replace(/[^\n]/g, ' '); + } + } else { + node.value = ''; + } + if (unmapLoc) { + unmapLoc(node.loc); + } +}; diff --git a/packages/evasive-transform/test/elide-comment.test.js b/packages/evasive-transform/test/elide-comment.test.js new file mode 100644 index 0000000000..ef58d44523 --- /dev/null +++ b/packages/evasive-transform/test/elide-comment.test.js @@ -0,0 +1,168 @@ +import { test } from './prepare-test-env-ava-fixture.js'; +import { elideComment } from '../src/transform-comment.js'; +import { evadeCensorSync } from '../src/index.js'; + +test('elideComment preserves the column width of the last and only line of a block comment', t => { + const comment = /** @type {import('@babel/types').Comment} */ ({ + type: 'CommentBlock', + value: ' hello world ', + }); + elideComment(comment); + t.is(comment.value, ' '); +}); + +test('elideComment erases non-final lines but preserves all newlines in a block comment', t => { + const comment = /** @type {import('@babel/types').Comment} */ ({ + type: 'CommentBlock', + value: ' * some\n * unnecessary information \n * the end', + }); + elideComment(comment); + t.is(comment.value, '\n\n '); +}); + +test('elideComment unconditionally elides line comments', t => { + const comment = /** @type {import('@babel/types').Comment} */ ({ + type: 'CommentLine', + value: ' hello', + }); + elideComment(comment); + t.is(comment.value, ''); +}); + +test('evadeCensor with elideComments erases the interior of block comments', t => { + const object = evadeCensorSync( + `/** + * Comment + * @param {type} name + */`, + { elideComments: true }, + ); + t.is( + object.code, + `/* + + + */`, + ); +}); + +test('evadeCensor with elideComments elides line comments', t => { + const object = evadeCensorSync(`// hello`, { elideComments: true }); + t.is(object.code, `//`); +}); + +test('evadeCensor with elideComments preserves bang comments', t => { + const object = evadeCensorSync(`/*! kris wuz here */`, { + elideComments: true, + }); + t.is(object.code, `/*! kris wuz here */`); +}); + +test('evadeCensor with elideComments preserves jsdoc @preserve comments', t => { + const comment = `/** + * @preserve + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments preserves initial jsdoc @preserve comments', t => { + const comment = `/** @preserve + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments preserves artless-but-valid jsdoc @preserve comments', t => { + const comment = `/** + @preserve + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments preserves jsdoc @copyright comments', t => { + const comment = `/** + * @copyright + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments preserves jsdoc @license comments', t => { + const comment = `/** + * @license + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments preserves jsdoc @cc_on comments', t => { + const comment = `/** + * @cc_on + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is(object.code, comment); +}); + +test('evadeCensor with elideComments does not preserve jsdoc @copyrighteous comments', t => { + const comment = `/** + * @copyrighteous + */`; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is( + object.code, + `/* + + */`, + ); +}); + +/* eslint-disable no-eval */ + +test('evadeCensor with elideComments preserves automatically-inserted-semicolon (ASI)', t => { + const comment = ` + (() => { + return /* + */ 42; + })(); + `; + const object = evadeCensorSync(comment, { + elideComments: true, + }); + t.is((0, eval)(comment), undefined); + t.is((0, eval)(object.code), undefined); +}); + +test('evadeCensor with stripComments preserves automatically-inserted-semicolon (ASI)', t => { + t.log( + 'There is no stripComments option. This is a trip-fall in case this is attempted.', + ); + const comment = ` + (() => { + return /* + */ 42; + })(); + `; + const object = evadeCensorSync(comment, { + stripComments: true, + }); + t.is((0, eval)(comment), undefined); + t.is((0, eval)(object.code), undefined); +}); + +/* eslint-enable no-eval */ diff --git a/packages/evasive-transform/test/transform-comment.test.js b/packages/evasive-transform/test/transform-comment.test.js index ce203f1589..5588546868 100644 --- a/packages/evasive-transform/test/transform-comment.test.js +++ b/packages/evasive-transform/test/transform-comment.test.js @@ -1,51 +1,51 @@ import { test } from './prepare-test-env-ava-fixture.js'; -import { transformComment } from '../src/transform-comment.js'; +import { evadeComment } from '../src/transform-comment.js'; -test('transformComment() - Node type becomes CommentBlock', async t => { +test('evadeComment() - Node type becomes CommentBlock', async t => { const comment = /** @type {import('@babel/types').Comment} */ ({ value: 'hello world', }); - transformComment(comment); + evadeComment(comment); t.is(comment.type, 'CommentBlock'); }); -test('transformComment() - strip extraneous leading whitespace', async t => { +test('evadeComment() - strip extraneous leading whitespace', async t => { const comment = /** @type {import('@babel/types').Comment} */ ({ type: 'CommentBlock', value: ' hello world ', }); - transformComment(comment); + evadeComment(comment); t.is(comment.value, ' hello world '); }); -test('transformComment() - defang HTML comment', async t => { +test('evadeComment() - defang HTML comment', async t => { const comment = /** @type {import('@babel/types').Comment} */ ({ type: 'CommentBlock', value: '', }); - transformComment(comment); + evadeComment(comment); t.is(comment.value, ''); }); -test('transformComment() - rewrite suspicious import(...)', async t => { +test('evadeComment() - rewrite suspicious import(...)', async t => { const comment = /** @type {import('@babel/types').Comment} */ ({ type: 'CommentBlock', value: `/** * @type {import('c:\\My Documents\\user.js')} */`, }); - transformComment(comment); + evadeComment(comment); t.regex( comment.value, new RegExp("\\* @type \\{IMPORT\\('c:\\\\My Documents\\\\user\\.js'\\)"), ); }); -test('transformComment() - rewrite end-of-comment marker', async t => { +test('evadeComment() - rewrite end-of-comment marker', async t => { const comment = /** @type {import('@babel/types').Comment} */ ({ type: 'CommentBlock', value: '/** I like turtles */', }); - transformComment(comment); + evadeComment(comment); t.is(comment.value, '/** I like turtles *X/'); });