Repository: endojs/endo
Pull request #2420 (2024-08-20 00:22:16)

feat: Elide comments in bundles
Closes: #2413 

## Description

Adds an option to the bundler to blank the interior of comments, reducing bundle sizes.

This change does not attempt to apply the elideComments behavior if the user selects noTransforms, since it is piggybacking on the censorship evasion transform. These features could be decoupled, elideComments works with endoZipBase64 and a narrower interpretation of noTransforms (no precompiled module transforms).

### Security Considerations

Some care has been taken to ensure that the resulting comments produce programs with the same behavior in the event the comment must be interpreted as a newline for automatic semicolon insertion (ASI).

### Scaling Considerations

None.

### Documentation Considerations

- NEWS
- README

### Testing Considerations

- evasive transform unit tests
- bundle source unit tests
  - covering endoScript and endoZipBase64
  - composition errors with noTransforms

Uncovered:
- cache behavior. The new flag participates in the cache and gracefully handles caches from prior versions.
- command line 

### Compatibility Considerations

Maintains support for caches from prior versions.

### Upgrade Considerations

None


Patch:
diff --git a/packages/bundle-source/NEWS.md b/packages/bundle-source/NEWS.md
index d93df151e..993e336f5 100644
--- a/packages/bundle-source/NEWS.md
+++ b/packages/bundle-source/NEWS.md
@@ -1,5 +1,12 @@
 User-visible changes to `@endo/bundle-source`:
 
+# Next version
+
+- Adds support for `--elide-comments` (`-e`) that blanks out the interior of
+  comments in `endoScript` and `endoZipBase64` formats to reduce bundle size
+  without compromising cursor position correspondence between source and bundle
+  code.
+
 # v3.3.0 (2024-07-30)
 
 - Adds support for `--no-transforms` (`-T`) which generates bundles with
diff --git a/packages/bundle-source/README.md b/packages/bundle-source/README.md
index 766b94f9e..7ce48d7d1 100644
--- a/packages/bundle-source/README.md
+++ b/packages/bundle-source/README.md
@@ -42,6 +42,21 @@ entry instead of `main` in `package.json`, if not overridden by explicit
 The `development` condition additionally implies that the bundle may import
 `devDependencies` from the package containing the entry module.
 
+## Comment Elision
+
+The `--elide-comments (`-e`) flag with `--format` (`-f`) `endoScript` or
+`endoZipBase64` (default) causes the bundler to blank out the interior of
+comments, without compromising line or column number cursor advancement.
+This can reduce bundle size without harming the debug experience any more than
+other transforms.
+
+Comment elision preserves `/*! slashasterbang /` comments and JSDoc comments
+with `@preserve`, `@copyright`, `@license` pragmas or the Internet Explorer
+`@cc_on` pragma.
+
+Comment elision does not strip comments entirely.
+The syntax to begin or end comments remains.
+
 ## Source maps
 
 With the `moduleFormat` of `endoZipBase64`, the bundler can generate source
diff --git a/packages/bundle-source/cache.js b/packages/bundle-source/cache.js
index 7a3640264..e61f6d5e0 100644
--- a/packages/bundle-source/cache.js
+++ b/packages/bundle-source/cache.js
@@ -19,6 +19,7 @@ const { Fail, quote: q } = assert;
  * @property {string} bundleTime ISO format
  * @property {number} bundleSize
  * @property {boolean} noTransforms
+ * @property {boolean} elideComments
  * @property {ModuleFormat} format
  * @property {string[]} conditions
  * @property {{ relative: string, absolute: string }} moduleSource
@@ -52,6 +53,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {Logger} [log]
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
+   * @param {boolean} [options.elideComments]
    * @param {string[]} [options.conditions]
    * @param {ModuleFormat} [options.format]
    */
@@ -60,6 +62,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
 
     const {
       noTransforms = false,
+      elideComments = false,
       format = 'endoZipBase64',
       conditions = [],
     } = options;
@@ -93,7 +96,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
 
     const bundle = await bundleSource(
       rootPath,
-      { ...bundleOptions, noTransforms, format, conditions },
+      { ...bundleOptions, noTransforms, elideComments, format, conditions },
       {
         ...readPowers,
         read: loggedRead,
@@ -122,6 +125,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         }),
       ),
       noTransforms,
+      elideComments,
       format,
       conditions,
     };
@@ -152,6 +156,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {BundleMeta} [meta]
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
+   * @param {boolean} [options.elideComments]
    * @param {ModuleFormat} [options.format]
    * @param {string[]} [options.conditions]
    * @returns {Promise<BundleMeta>}
@@ -166,6 +171,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
     await null;
     const {
       noTransforms: expectedNoTransforms = false,
+      elideComments: expectedElideComments = false,
       format: expectedFormat = DEFAULT_MODULE_FORMAT,
       conditions: expectedConditions = [],
     } = options;
@@ -194,12 +200,14 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
       moduleSource: { absolute: moduleSource },
       format = 'endoZipBase64',
       noTransforms = false,
+      elideComments = false,
       conditions = [],
     } = meta;
     conditions.sort();
     assert.equal(bundleFileName, toBundleName(targetName));
     assert.equal(format, expectedFormat);
     assert.equal(noTransforms, expectedNoTransforms);
+    assert.equal(elideComments, expectedElideComments);
     assert.equal(conditions.length, expectedConditions.length);
     conditions.forEach((tag, index) => {
       assert.equal(tag, expectedConditions[index]);
@@ -249,6 +257,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {Logger} [log]
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
+   * @param {boolean} [options.elideComments]
    * @param {ModuleFormat} [options.format]
    * @param {string[]} [options.conditions]
    * @returns {Promise<BundleMeta>}
@@ -269,6 +278,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         meta = await validate(targetName, rootPath, log, meta, {
           format: options.format,
           noTransforms: options.noTransforms,
+          elideComments: options.elideComments,
           conditions: options.conditions,
         });
         const {
@@ -276,6 +286,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
           bundleSize,
           contents,
           noTransforms,
+          elideComments,
           format = 'endoZipBase64',
           conditions = [],
         } = meta;
@@ -288,7 +299,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
           bundleTime,
           'with size',
           bundleSize,
-          noTransforms ? 'w/o transforms' : 'with transforms',
+          `${noTransforms ? 'w/o transforms' : 'with transforms'}${elideComments ? ' and comments elided' : ''}`,
           'with format',
           format,
           'and conditions',
@@ -308,6 +319,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         bundleTime,
         contents,
         noTransforms,
+        elideComments,
         format = 'endoZipBase64',
         conditions = [],
       } = meta;
@@ -319,7 +331,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
         bundleFileName,
         'at',
         bundleTime,
-        noTransforms ? 'w/o transforms' : 'with transforms',
+        `${noTransforms ? 'w/o transforms' : 'with transforms'}${elideComments ? ' and comments elided' : ''}`,
         'with format',
         format,
         'and conditions',
@@ -337,6 +349,7 @@ export const makeBundleCache = (wr, cwd, readPowers, opts) => {
    * @param {Logger} [log]
    * @param {object} [options]
    * @param {boolean} [options.noTransforms]
+   * @param {boolean} [options.elideComments]
    * @param {ModuleFormat} [options.format]
    * @param {string[]} [options.conditions]
    */
diff --git a/packages/bundle-source/demo/meaningful.js b/packages/bundle-source/demo/meaningful.js
index 5d3808dcf..5ed1e40e7 100644
--- a/packages/bundle-source/demo/meaningful.js
+++ b/packages/bundle-source/demo/meaningful.js
@@ -1,3 +1,7 @@
 import json from './meaning.json';
 
+/**
+ * This comment will be blanked with the elideComments / --elide-comments
+ * feature enabled.
+ */
 export const meaning = json.meaning;
diff --git a/packages/bundle-source/src/endo.js b/packages/bundle-source/src/endo.js
index 6f9c8f5d5..0608087f7 100644
--- a/packages/bundle-source/src/endo.js
+++ b/packages/bundle-source/src/endo.js
@@ -12,8 +12,14 @@ const textDecoder = new TextDecoder();
 
 export const makeBundlingKit = (
   { pathResolve, userInfo, computeSha512, platform, env },
-  { cacheSourceMaps, noTransforms, commonDependencies, dev },
+  { cacheSourceMaps, elideComments, noTransforms, commonDependencies, dev },
 ) => {
+  if (noTransforms && elideComments) {
+    throw new Error(
+      'bundleSource endoZipBase64 cannot elideComments with noTransforms',
+    );
+  }
+
   const sourceMapJobs = new Set();
   let writeSourceMap = Function.prototype;
   if (cacheSourceMaps) {
@@ -112,6 +118,7 @@ export const makeBundlingKit = (
       sourceType: babelSourceType,
       sourceMap,
       sourceMapUrl: new URL(specifier, location).href,
+      elideComments,
     }));
     const objectBytes = textEncoder.encode(object);
     return { bytes: objectBytes, parser, sourceMap };
diff --git a/packages/bundle-source/src/main.js b/packages/bundle-source/src/main.js
index c8b54a720..a15fc861f 100644
--- a/packages/bundle-source/src/main.js
+++ b/packages/bundle-source/src/main.js
@@ -13,7 +13,8 @@ bundle-source [-Tft] --cache-js|--cache-json <cache/> (<entry.js> <bundle-name>)
   -f,--format endoZipBase64*|nestedEvaluate|getExport
   -C,--condition <condition> (browser, node, development, &c)
   -C development (to access devDependencies)
-  -T,--no-transforms`;
+  -T,--no-transforms
+  -e,--elide-comments`;
 
 const options = /** @type {const} */ ({
   'no-transforms': {
@@ -21,6 +22,11 @@ const options = /** @type {const} */ ({
     short: 'T',
     multiple: false,
   },
+  'elide-comments': {
+    type: 'boolean',
+    short: 'e',
+    multiple: false,
+  },
   'cache-js': {
     type: 'string',
     multiple: false,
@@ -61,6 +67,7 @@ export const main = async (args, { loadModule, pid, log }) => {
       format: moduleFormat = 'endoZipBase64',
       condition: conditions = [],
       'no-transforms': noTransforms,
+      'elide-comments': elideComments,
       'cache-json': cacheJson,
       'cache-js': cacheJs,
       // deprecated
@@ -69,6 +76,11 @@ export const main = async (args, { loadModule, pid, log }) => {
     positionals,
   } = parseArgs({ args, options, allowPositionals: true });
 
+  if (noTransforms && elideComments) {
+    throw Error(
+      `Cannot elide comments without transforms (-T,--no-transforms + -e,--elide-comments)`,
+    );
+  }
   if (!SUPPORTED_FORMATS.includes(moduleFormat)) {
     throw Error(`Unsupported format: ${moduleFormat}\n\n${USAGE}`);
   }
@@ -94,6 +106,7 @@ export const main = async (args, { loadModule, pid, log }) => {
     const [entryPath] = positionals;
     const bundle = await bundleSource(entryPath, {
       noTransforms,
+      elideComments,
       format,
       conditions,
     });
@@ -125,6 +138,7 @@ export const main = async (args, { loadModule, pid, log }) => {
     // eslint-disable-next-line no-await-in-loop
     await cache.validateOrAdd(bundleRoot, bundleName, undefined, {
       noTransforms,
+      elideComments,
       format,
       conditions,
     });
diff --git a/packages/bundle-source/src/script.js b/packages/bundle-source/src/script.js
index 3e08e8347..c8f633a5b 100644
--- a/packages/bundle-source/src/script.js
+++ b/packages/bundle-source/src/script.js
@@ -20,6 +20,7 @@ const readPowers = makeReadPowers({ fs, url, crypto });
  * @param {boolean} [options.dev]
  * @param {boolean} [options.cacheSourceMaps]
  * @param {boolean} [options.noTransforms]
+ * @param {boolean} [options.elideComments]
  * @param {Record<string, string>} [options.commonDependencies]
  * @param {object} [grantedPowers]
  * @param {(bytes: string | Uint8Array) => string} [grantedPowers.computeSha512]
@@ -37,9 +38,11 @@ export async function bundleScript(
     dev = false,
     cacheSourceMaps = false,
     noTransforms = false,
+    elideComments = false,
     conditions = [],
     commonDependencies,
   } = options;
+
   const powers = { ...readPowers, ...grantedPowers };
   const {
     computeSha512,
@@ -63,6 +66,7 @@ export async function bundleScript(
       {
         cacheSourceMaps,
         noTransforms,
+        elideComments,
         commonDependencies,
         dev,
       },
diff --git a/packages/bundle-source/src/types.js b/packages/bundle-source/src/types.js
index 087e0adab..617c17f20 100644
--- a/packages/bundle-source/src/types.js
+++ b/packages/bundle-source/src/types.js
@@ -67,6 +67,9 @@ export {};
  * @property {T} [format]
  * @property {boolean} [dev] - development mode, for test bundles that need
  * access to devDependencies of the entry package.
+ * @property {boolean} [elideComments] - when true for the `endoScript` and
+ * `endoZipBase64` format, replaces the interior of comments with blank space
+ * that advances the cursor the same number of lines and columns.
  * @property {boolean} [noTransforms] - when true, generates a bundle with the
  * original sources instead of SES-shim specific ESM and CJS. This may become
  * default in a future major version.
diff --git a/packages/bundle-source/src/zip-base64.js b/packages/bundle-source/src/zip-base64.js
index d097e9d63..6f5de55fa 100644
--- a/packages/bundle-source/src/zip-base64.js
+++ b/packages/bundle-source/src/zip-base64.js
@@ -22,6 +22,7 @@ const readPowers = makeReadPowers({ fs, url, crypto });
  * @param {boolean} [options.dev]
  * @param {boolean} [options.cacheSourceMaps]
  * @param {boolean} [options.noTransforms]
+ * @param {boolean} [options.elideComments]
  * @param {string[]} [options.conditions]
  * @param {Record<string, string>} [options.commonDependencies]
  * @param {object} [grantedPowers]
@@ -40,6 +41,7 @@ export async function bundleZipBase64(
     dev = false,
     cacheSourceMaps = false,
     noTransforms = false,
+    elideComments = false,
     conditions = [],
     commonDependencies,
   } = options;
@@ -66,6 +68,7 @@ export async function bundleZipBase64(
       {
         cacheSourceMaps,
         noTransforms,
+        elideComments,
         commonDependencies,
         dev,
       },
diff --git a/packages/evasive-transform/NEWS.md b/packages/evasive-transform/NEWS.md
index e69de29bb..cb1c1081c 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 01fb56a49..cb3cc0025 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 68bc689ec..de7aca4d3 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 99764b31e..1053c78b5 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);
+  }
+};


Test patch:
diff --git a/packages/bundle-source/test/endo-script-format.tests.js b/packages/bundle-source/test/endo-script-format.tests.js
index c1146bbeb..d0462a994 100644
--- a/packages/bundle-source/test/endo-script-format.tests.js
+++ b/packages/bundle-source/test/endo-script-format.tests.js
@@ -4,16 +4,30 @@ import test from '@endo/ses-ava/prepare-endo.js';
 import * as url from 'url';
 import bundleSource from '../src/index.js';
 
-test('endo script format', async t => {
+const generate = async (options = {}) => {
   const entryPath = url.fileURLToPath(
     new URL(`../demo/meaning.js`, import.meta.url),
   );
-  const bundle = await bundleSource(entryPath, {
+  return bundleSource(entryPath, {
     format: 'endoScript',
+    ...options,
   });
+};
+
+test('endo script format', async t => {
+  const bundle = await generate();
   t.is(bundle.moduleFormat, 'endoScript');
   const { source } = bundle;
   const compartment = new Compartment();
   const ns = compartment.evaluate(source);
   t.is(ns.meaning, 42);
 });
+
+test('endo script format is smaller with blank comments', async t => {
+  const bigBundle = await generate();
+  const smallBundle = await generate({ elideComments: true });
+  const compartment = new Compartment();
+  const ns = compartment.evaluate(smallBundle.source);
+  t.is(ns.meaning, 42);
+  t.assert(smallBundle.source.length < bigBundle.source.length);
+});
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 000000000..ef58d4452
--- /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 ce203f158..558854686 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: '<!-- evil code -->',
   });
-  transformComment(comment);
+  evadeComment(comment);
   t.is(comment.value, '<!=- evil code -=>');
 });
 
-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/');
 });