Skip to content

If we are using ember 7.1 or newer, we should lint against imports from ember-element-helper, ember-truth-helpers, etc #2819

Description

@NullVoxPopuli

We can have an autofix as well.

we can autofix if there are no import specifier conflicts.

reference: one shot codemod, I did that doesn't cover all the cases, but was decent
import { toTree, print } from 'ember-estree';
import { glob, readFile, writeFile } from 'node:fs/promises';


const REMOVE_IMPORT = 'REMOVE_IMPORT';
const RENAME = 'RENAME';

const MAP = {
  'ember-truth-helpers': {
    eq: REMOVE_IMPORT,
    notEq: [[RENAME, 'neq'], REMOVE_IMPORT], 
    not: REMOVE_IMPORT,
    and: REMOVE_IMPORT,
    or: REMOVE_IMPORT,
    gt: REMOVE_IMPORT,
    gte: REMOVE_IMPORT,
    lt: REMOVE_IMPORT,
    lte: REMOVE_IMPORT,
  },
  'ember-truth-helpers/helpers/not-eq': { default: [[RENAME, 'neq'], REMOVE_IMPORT] },
  'ember-truth-helpers/helpers/not': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/eq': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/and': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/or': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/gt': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/gte': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/lt': { default: REMOVE_IMPORT },
  'ember-truth-helpers/helpers/lte': { default: REMOVE_IMPORT },
  '@ember/helper': {
    array: [
      // [RENAME, 'Array'], 
      REMOVE_IMPORT],
    hash: [
      //[RENAME, 'Object'], 
      REMOVE_IMPORT],
    fn: REMOVE_IMPORT,
  },
  'ember-element-helper': {
    'element': REMOVE_IMPORT,
    // Bare default import can't be derived from the path — name it explicitly
    // so an aliased local (e.g. `import element_ from 'ember-element-helper'`)
    // has its usages renamed to the `element` keyword.
    default: [[RENAME, 'element'], REMOVE_IMPORT],
  },
  'ember-element-helper/helpers/element': {
    default: REMOVE_IMPORT,
  },
  '@ember/modifier': {
    on: REMOVE_IMPORT,
  },
  '@ember/object/computed': legacyPrefix,
}

function updateImports(contents) {
  /**
   * localName -> newName, collected while walking import specifiers and
   * applied to every usage (JS identifiers + Glimmer template paths).
   * ImportDeclarations are visited before usages, so this is populated in time.
   */
  let renames = new Map();

  /**
   * Span-based patches against the *original* source: `{ start, end, text }`.
   * We only re-emit the ranges we actually change and splice them into the
   * untouched original — so comments, formatting, and unrelated code are
   * preserved byte-for-byte (the full-program `print()` reformatted everything).
   */
  let edits = [];

  toTree(contents, {
    visitors: {
      ImportDeclaration(node) {
        let rule = MAP[node.source.value];

        if (!rule) return;

        // A rule is either a per-specifier object or a function of the
        // imported name (wildcard rules like `legacyPrefix`).
        let lookup = typeof rule === 'function' ? rule : (name) => rule[name];

        let removed = new Set();
        let changed = false;

        for (let specifier of node.specifiers) {
          // 'default' for `import X from ...`, otherwise the imported name.
          let importedName =
            specifier.type === 'ImportDefaultSpecifier'
              ? 'default'
              : (specifier.imported?.name ?? specifier.imported?.value);

          let value = lookup(importedName);

          if (value == null) continue;

          let actions = normalizeActions(value);

          if (actions.some((a) => a.type === REMOVE_IMPORT)) {
            // Removal wins — the binding disappears. Usages must point at the
            // canonical keyword name: an explicit RENAME target if given,
            // otherwise the imported/keyword name. This catches aliased locals
            // (e.g. `import element_ from '.../helpers/element'`, used as
            // `(element_ …)`) that would otherwise dangle as undefined.
            let rename = actions.find((a) => a.type === RENAME);
            let target = rename ? rename.to : canonicalName(specifier, node.source.value);
            if (target && target !== specifier.local.name) {
              renames.set(specifier.local.name, target);
            }
            removed.add(specifier);
            changed = true;
            continue;
          }

          // RENAME with no REMOVE_IMPORT: alias the local binding, keeping the
          // imported name (`and` -> `and as legacyAnd`), and rename usages of
          // the old local name. Skip when the local name wouldn't change.
          let rename = actions.find((a) => a.type === RENAME);

          if (rename && rename.to !== specifier.local.name) {
            renames.set(specifier.local.name, rename.to);
            specifier.local.name = rename.to;
            changed = true;
          }
        }

        if (!changed) return;

        let [start, end] = span(node);
        let remaining = node.specifiers.filter((s) => !removed.has(s));

        if (remaining.length === 0) {
          // Nothing left to import — drop the whole line, trailing newline too.
          edits.push({ start, end: consumeTrailingNewline(contents, end), text: '' });
        } else {
          // Some specifiers survive — re-print just this declaration.
          node.specifiers = remaining;
          edits.push({ start, end, text: print(node) });
        }
      },

      Identifier(node, path) {
        let to = renames.get(node.name);

        if (!to) return;

        let parent = path.parent;

        // Skip non-reference positions (property access / object keys).
        if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) return;
        if (parent?.type === 'Property' && parent.key === node && !parent.computed) return;
        // Import/export specifier identifiers are handled at the declaration
        // level; renaming them here would overlap that edit.
        if (isImportExportSpecifier(parent?.type)) return;

        let [start, end] = span(node);
        edits.push({ start, end, text: to });
      },

      GlimmerPathExpression(node) {
        let to = renames.get(node.original);

        if (!to) return;

        let [start, end] = span(node);
        edits.push({ start, end, text: to });
      },
    },
  });

  return applyEdits(contents, edits);
}

/** Absolute [start, end) source offsets for a node (ESTree or Glimmer). */
function span(node) {
  return [node.start ?? node.range?.[0], node.end ?? node.range?.[1]];
}

/**
 * The canonical keyword name a removed helper import collapses to, so aliased
 * locals don't leave dangling usages:
 *   - named import  -> the imported name (`{ eq as e }` -> `eq`)
 *   - default import -> the `.../helpers/<name>` (or `/modifiers/`) path segment
 *     (`import element_ from '.../helpers/element'` -> `element`)
 * Returns null when no canonical name can be determined.
 */
function canonicalName(specifier, source) {
  if (specifier.type === 'ImportSpecifier') {
    return specifier.imported?.name ?? specifier.imported?.value ?? null;
  }

  if (specifier.type === 'ImportDefaultSpecifier') {
    let match = source.match(/\/(?:helpers|modifiers)\/([^/]+)$/);
    return match ? match[1] : null;
  }

  return null;
}

function isImportExportSpecifier(type) {
  return (
    type === 'ImportSpecifier' ||
    type === 'ImportDefaultSpecifier' ||
    type === 'ImportNamespaceSpecifier' ||
    type === 'ExportSpecifier'
  );
}

/** Extend `end` past trailing horizontal whitespace and one line break. */
function consumeTrailingNewline(text, end) {
  let i = end;
  while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i++;
  if (text[i] === '\r') i++;
  if (text[i] === '\n') i++;
  return i;
}

/** Splice edits into the source right-to-left so earlier offsets stay valid. */
function applyEdits(text, edits) {
  let sorted = [...edits].sort((a, b) => b.start - a.start);
  let out = text;
  let lastStart = Infinity;

  for (let { start, end, text: replacement } of sorted) {
    if (end > lastStart) continue; // defensive: skip any overlapping edit
    out = out.slice(0, start) + replacement + out.slice(end);
    lastStart = start;
  }

  return out;
}

async function main() {
  let [,,file] = process.argv;

  if (file) {
    await transformFile(file);
    return;
  }

  for await (const file of glob('**/*.{gjs,gts}', { exclude: [
    'node_modules/**',
    '**/node_modules/**',
    '**/blueprints/**',
  ] })) {
    await waitForRoom();

    let promise = transformFile(file);

    addToBucket(promise);

  }

  // Let the final in-flight batch finish before the process exits.
  await Promise.allSettled([...bucket]);
  console.log(`Done: ${done} files`);
}


/*******************************************
 *
 * low-level (ish) helpers for the above
 *  
 *******************************************/

/**
 * Wildcard rule: alias every *named* import from a module with a `legacy`
 * prefix (`and` -> `and as legacyAnd`) and update its usages. Default imports
 * are left alone. A module's MAP entry may be this function instead of a
 * per-specifier object.
 */
function legacyPrefix(importedName) {
  if (importedName === 'default') return null;
  return [RENAME, `legacy${importedName[0].toUpperCase()}${importedName.slice(1)}`];
}

/**
 * Normalize a MAP value into an ordered list of actions.
 *
 * A value is one of:
 *   - REMOVE_IMPORT                       (a single action)
 *   - [RENAME, 'newName']                 (a single action)
 *   - [[RENAME, 'newName'], REMOVE_IMPORT]  (a list of actions, applied in order)
 */
function normalizeActions(value) {
  let raw = Array.isArray(value) && value[0] === RENAME ? [value] : value;
  let list = Array.isArray(raw) ? raw : [raw];

  return list.map((action) => {
    if (action === REMOVE_IMPORT) return { type: REMOVE_IMPORT };
    if (Array.isArray(action) && action[0] === RENAME) {
      return { type: RENAME, to: action[1] };
    }
    throw new Error(`Unknown action in MAP: ${JSON.stringify(action)}`);
  });
}

const bucketSize = 100;
const flex = 50;
const bucket = new Set();
// Resolver for a pending waitForRoom(), if the loop is currently blocked.
let roomResolve = null;
  let done = 0;

function addToBucket(promise) {
  bucket.add(promise);

  promise.finally(() => {
    bucket.delete(promise);

    // Wake the loop once we've drained back down to the low-water mark.
    if (roomResolve && bucket.size <= flex) {
      roomResolve();
      roomResolve = null;
      done++;
    }
  });
}


async function transformFile(filePath) {
  let buffer = await readFile(filePath);
  let content = buffer.toString();

  try {
  let output = updateImports(content);

  if (output !== content) {
    await writeFile(filePath, output) 
  }
  } catch (e) {
    console.error(filePath);
    console.error(e);
    process.exit(1);
  }
}

function waitForRoom() {
  if (bucket.size < bucketSize) {
    console.log(`Finished ${done} of  ¯\\_(ツ)_/¯`);
    return;
  }

  console.log(`Bucket has ${bucket.size}... waiting`);

  // Resolved by addToBucket()'s finally handler once the bucket drains to
  // `flex`. Event-driven — no polling timer to leak or to keep the process
  // alive after the work is done.
  return new Promise((resolve) => {
    roomResolve = resolve;
  });
}



/*******************************************
 *
 * now that everything is defined: begin! 
 * 
 *******************************************/
main().catch((e) => {
  console.error(e);
  process.exit(1);
});

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions