Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ paths:

# Plugin system

94 built-in plugins implementing the `Plugin` trait with enablers (package.json detection), static patterns, and optional `resolve_config()` for AST-based config parsing.
95 built-in plugins implementing the `Plugin` trait with enablers (package.json detection), static patterns, and optional `resolve_config()` for AST-based config parsing.

## Rich config parsing (16 plugins)

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Ember.js / Glimmer / Embroider plugin.** New built-in plugin activates on `ember-source`, `ember-cli`, `@embroider/core`, `@embroider/compat`, or `@glimmer/component`. Whitelists the build- / CLI- / runtime-resolved tooling that no source file imports (`ember-source` itself, `ember-cli`, `ember-cli-htmlbars`, `ember-cli-babel`, `ember-auto-import`, `@embroider/core`, `@glint/core` + the two glint environment shims, `ember-cli-test-loader`, `ember-exam`, `ember-template-lint`, `ember-template-imports`, `@ember/optional-features`, `loader.js`, ...) so those packages do not surface as `unused-dependency`. Packages a modern Ember app imports directly (`@glimmer/component`, `@glimmer/tracking`, `@ember-data/*`, `@embroider/{compat,webpack,vite,addon-shim,macros,router,test-setup}`, `@glint/template`, `@ember/test-helpers`, `ember-qunit`, `qunit`, `qunit-dom`, `ember-load-initializers`, `ember-resolver`, ...) are deliberately omitted; the normal import graph credits them, and listing them in the tooling allowlist would mask real removals when a user drops the dependency. Declares scoped used-class-member rules for `Component`, `Route`, `Controller`, `Service`, `Helper`, `Modifier`, `Application`, and `Router` so framework-invoked lifecycle methods (`model`, `setupController`, `compute`, `modify`, `willDestroy`, `didInsertElement`, ...) are not flagged as unused on subclasses. Exposes Ember's classic-layout filesystem conventions (`app/components/**`, `app/routes/**`, `app/services/**`, `addon/**`, `tests/**/*-test.{js,ts,gjs,gts}`, `config/`, `ember-cli-build.js`, `testem.js`) as entry-point globs since the Ember resolver loads those modules by convention rather than via static `import`. `.gts` / `.gjs` single-file components were already parseable thanks to the existing `<template>`-stripping helper; tracking imports referenced only inside `<template>` blocks (and inside co-located `.hbs` templates) is intentionally deferred to a follow-up that will extend the same scaffolding with `sfc_template`-style scanning.
- **Glimmer `<template>` blocks credit imported-binding usage in `.gts` / `.gjs`.** Imports referenced only inside a `<template>...</template>` block — PascalCase tag invocation (`<HelloWorld />`), mustache helper (`{{capitalize x}}`), sub-expression helper (`{{if (and a b) "y" "n"}}`), element modifier (`{{on "click" handle}}`), dotted reference (`{{utils.formatDate value}}`) — are no longer flagged as `unused-import`. Handlebars/Glimmer built-in keywords (`if`, `unless`, `each`, `let`, `yield`, ...), `this.*` chains, `@arg` references, and named-argument keys are never resolved as imports. Block-parameter introductions (`as |item index|`) are accumulated as template-scope locals so they shadow same-named imports. Co-located `.hbs` templates remain a known limitation: imports referenced only inside a sibling `.hbs` file still surface as unused on the sibling `.js`/`.ts`; the plugin's `entry_patterns` keep the JS sibling reachable as a file, and migrating to `.gts` removes the limitation entirely.

### Fixed

- **VS Code: tree view no longer crashes with `The "path" argument must be of type string. Received undefined` when an unlisted dependency is present.** The extension's TypeScript type for `UnlistedDependency` had drifted from the Rust struct: it declared a single top-level `path` field but the actual JSON shape carries `imported_from: ImportSite[]` (so one unlisted dependency reports every site that imports it, with per-site file / line / column). When the user's project produced at least one unlisted dependency, the tree view's `unlisted-dependencies` mapping passed `d.path` (`undefined`) into Node's `path.isAbsolute`, which threw and prevented the tree from rendering, so the "Unused Code" tab silently fell back to the empty "No unused code found" welcome message. The fix updates the TypeScript type to match the Rust shape, mirrors the existing `duplicate_exports.locations.flatMap` pattern to emit one tree row per import site (so each row navigates to the actual import line), and hardens `resolveFilePath` to return empty strings on undefined / empty input as a defensive guard against any future shape drift. The audit also surfaced two latent type drifts in the same file that hadn't crashed yet only because no consumer happened to read the missing fields: `UnusedDependency` now declares the `location` (`"dependencies" | "devDependencies" | "optionalDependencies"`), `line`, and optional `used_in_workspaces` fields that the Rust struct has always emitted, and `TypeOnlyDependency` now declares the `line` field. Thanks [@FunctionDJ](https://github.com/FunctionDJ) for the report. (Closes [#323](https://github.com/fallow-rs/fallow/issues/323))
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Health Summary

**Static analysis is free and open source. Runtime intelligence is optional.**

94 framework plugins. No Node.js runtime required for static analysis. No config needed for the first run.
95 framework plugins. No Node.js runtime required for static analysis. No config needed for the first run.

Fallow builds a project-wide understanding of your TS/JS codebase instead of checking one file at a time. Use it to review AI-generated changes faster, clean up dead code, reduce duplication, find risky complexity, and enforce architecture boundaries. Add the runtime layer when you want to know what actually executed in production.

Expand Down Expand Up @@ -490,11 +490,11 @@ See the [full configuration reference](https://docs.fallow.tools/configuration/o

## Framework plugins

94 built-in plugins detect entry points, convention exports, config-defined aliases, and template-visible usage for your framework automatically.
95 built-in plugins detect entry points, convention exports, config-defined aliases, and template-visible usage for your framework automatically.

| Category | Plugins |
|---|---|
| **Frameworks** | Next.js, Nuxt, Remix, Qwik, SvelteKit, Gatsby, Astro, Angular, NestJS, Lit, Expo, Expo Router, Electron, and more |
| **Frameworks** | Next.js, Nuxt, Remix, Qwik, SvelteKit, Gatsby, Astro, Angular, NestJS, Lit, Ember, Expo, Expo Router, Electron, and more |
| **Bundlers** | Vite, Webpack, Rspack, Rsbuild, Rollup, Rolldown, Tsup, Tsdown, Parcel |
| **Testing** | Vitest, Jest, Playwright, Cypress, Storybook, Mocha, Ava, tap, tsd |
| **CSS** | Tailwind, PostCSS, UnoCSS, PandaCSS |
Expand Down
330 changes: 330 additions & 0 deletions crates/core/src/plugins/ember.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
//! Ember.js / Glimmer / Embroider plugin.
//!
//! Activates on `ember-source`, `ember-cli`, `@embroider/core`,
//! `@embroider/compat`, or `@glimmer/component` dependencies. Tracks Ember's
//! build, test, and runtime tooling deps (so they are not flagged as unused),
//! whitelists the lifecycle and reflectively-invoked members on Ember's class
//! hierarchy, and exposes Ember's filesystem-resolved conventions (the classic
//! `app/`, `addon/`, and `tests/` layouts) as entry-point globs since those
//! files are loaded by the Ember resolver rather than by static `import`.
//!
//! Template-block import tracking (`<template>...</template>`, `.gjs`/`.gts`
//! single-file components, and `.hbs` references) is handled separately by the
//! Glimmer-aware extractor in `crates/extract/src/glimmer.rs`. Decorator-form
//! component, service, helper, and modifier registration (`@classic`,
//! `@service`, `@tracked`, `@action`) flows through the visitor and is not
//! re-implemented here. This plugin only handles the lifecycle and convention
//! members that the framework calls reflectively at runtime.

use fallow_config::{ScopedUsedClassMemberRule, UsedClassMemberRule};

use super::Plugin;

const ENABLERS: &[&str] = &[
"ember-source",
"ember-cli",
"@embroider/core",
"@embroider/compat",
"@glimmer/component",
];

/// Packages required by an Ember project but never statically imported from
/// source. Anything an `import` statement can reach in a modern Ember app
/// (`@glimmer/component`, `@ember/test-helpers`, `@ember-data/*`, ...) is
/// intentionally omitted: the normal import graph already credits those, so
/// listing them here would only mask real removals when a user genuinely
/// drops a dependency.
const TOOLING_DEPENDENCIES: &[&str] = &[
// Core Ember runtime / build pipeline
//
// `ember-source` is the meta-package: source code imports through the
// `@ember/*` namespace (e.g. `@ember/application`, `@ember/routing/route`)
// and never references the `ember-source` specifier directly.
"ember-source",
"ember-cli",
"ember-cli-htmlbars",
"ember-cli-babel",
"ember-auto-import",
// Embroider runtime core. The compat / webpack / vite halves are
// `require()`'d from `ember-cli-build.js` (which is an entry pattern),
// and `@embroider/addon-shim` is `require()`'d from each v2 addon's
// `index.js` (reached via `package.json#main`); they're credited through
// the normal import graph and don't need an allowlist entry. The macros /
// router / test-setup halves are imported from source and likewise rely
// on the import graph.
"@embroider/core",
// Glint type-checker CLI + tsconfig environment shims (`@glint/template`
// IS imported as type-only and so is omitted here).
"@glint/core",
"@glint/environment-ember-loose",
"@glint/environment-ember-template-imports",
// Test infrastructure invoked by the runner, not imported from source
// (`ember-qunit`, `qunit`, `qunit-dom`, `@ember/test-helpers` are imported
// and so are omitted here).
"ember-cli-test-loader",
"ember-exam",
// Common addons that act through ember-cli config, package.json keys, or
// the build server rather than via source imports.
"ember-template-lint",
"ember-template-imports",
"ember-source-channel-url",
"@ember/optional-features",
"ember-cli-dependency-checker",
"ember-cli-inject-live-reload",
"ember-cli-sri",
"ember-cli-terser",
"loader.js",
];

/// Glimmer / classic Ember component lifecycle members called by the framework
/// at runtime. Covers both `@glimmer/component` and the legacy
/// `@ember/component` class hierarchy.
const COMPONENT_MEMBERS: &[&str] = &[
"willDestroy",
"didInsertElement",
"didRender",
"didUpdate",
"didReceiveAttrs",
"willRender",
"willUpdate",
"willClearRender",
"willDestroyElement",
"didDestroyElement",
];

/// Route hooks called by the Ember router during transitions, plus the
/// convention properties (`actions`, `queryParams`, `templateName`,
/// `controllerName`) that the resolver reads reflectively.
const ROUTE_MEMBERS: &[&str] = &[
"model",
"beforeModel",
"afterModel",
"setupController",
"resetController",
"redirect",
"serialize",
"deserialize",
"activate",
"deactivate",
"actions",
"queryParams",
"templateName",
"controllerName",
];

const CONTROLLER_MEMBERS: &[&str] = &["actions", "queryParams"];

const SERVICE_MEMBERS: &[&str] = &["init", "willDestroy"];

const HELPER_MEMBERS: &[&str] = &["compute", "recompute"];

const MODIFIER_MEMBERS: &[&str] = &[
"modify",
"willDestroy",
"didReceiveArguments",
"didInstall",
"didUpdateArguments",
"willRemove",
];

const APPLICATION_MEMBERS: &[&str] = &["ready", "willDestroy", "init"];

const ROUTER_MEMBERS: &[&str] = &[
"map",
"location",
"rootURL",
"willTransition",
"didTransition",
];

const ENTRY_PATTERNS: &[&str] = &[
// Classic app/ layout
"app/app.{js,ts,gjs,gts}",
"app/router.{js,ts}",
"app/index.html",
"app/components/**/*.{js,ts,gjs,gts,hbs}",
"app/routes/**/*.{js,ts,gjs,gts}",
"app/controllers/**/*.{js,ts}",
"app/templates/**/*.{hbs,gjs,gts}",
"app/models/**/*.{js,ts}",
"app/services/**/*.{js,ts}",
"app/helpers/**/*.{js,ts,gjs,gts}",
"app/modifiers/**/*.{js,ts}",
"app/initializers/**/*.{js,ts}",
"app/instance-initializers/**/*.{js,ts}",
"app/adapters/**/*.{js,ts}",
"app/serializers/**/*.{js,ts}",
"app/transforms/**/*.{js,ts}",
// v1 addon layout
"addon/**/*.{js,ts,gjs,gts,hbs}",
"addon-test-support/**/*.{js,ts,gjs,gts}",
// Tests
"tests/test-helper.{js,ts}",
"tests/index.html",
"tests/**/*-test.{js,ts,gjs,gts}",
// Build / config
"config/environment.js",
"config/targets.js",
"config/optional-features.json",
"config/deprecation-workflow.js",
"ember-cli-build.js",
"testem.js",
];

fn scoped_rule(extends: &str, members: &[&str]) -> UsedClassMemberRule {
UsedClassMemberRule::Scoped(ScopedUsedClassMemberRule {
extends: Some(extends.to_string()),
implements: None,
members: members.iter().map(|s| (*s).to_string()).collect(),
})
}

pub struct EmberPlugin;

impl Plugin for EmberPlugin {
fn name(&self) -> &'static str {
"ember"
}

fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}

fn tooling_dependencies(&self) -> &'static [&'static str] {
TOOLING_DEPENDENCIES
}

fn used_class_member_rules(&self) -> Vec<UsedClassMemberRule> {
vec![
scoped_rule("Component", COMPONENT_MEMBERS),
scoped_rule("Route", ROUTE_MEMBERS),
scoped_rule("Controller", CONTROLLER_MEMBERS),
scoped_rule("Service", SERVICE_MEMBERS),
scoped_rule("Helper", HELPER_MEMBERS),
scoped_rule("Modifier", MODIFIER_MEMBERS),
scoped_rule("Application", APPLICATION_MEMBERS),
scoped_rule("Router", ROUTER_MEMBERS),
]
}

fn entry_patterns(&self) -> &'static [&'static str] {
ENTRY_PATTERNS
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn enablers_cover_classic_embroider_and_glimmer() {
let plugin = EmberPlugin;
assert!(plugin.enablers().contains(&"ember-source"));
assert!(plugin.enablers().contains(&"@embroider/core"));
assert!(plugin.enablers().contains(&"@glimmer/component"));
}

#[test]
fn tooling_dependencies_cover_runtime_only_packages() {
let plugin = EmberPlugin;
let deps = plugin.tooling_dependencies();
// Build / CLI / config-only packages that no source file imports must
// be credited via the tooling list.
assert!(deps.contains(&"ember-source"));
assert!(deps.contains(&"ember-cli-htmlbars"));
assert!(deps.contains(&"@embroider/core"));
assert!(deps.contains(&"@glint/core"));
assert!(deps.contains(&"ember-exam"));
assert!(deps.contains(&"loader.js"));
}

#[test]
fn tooling_dependencies_omits_source_imported_packages() {
// Packages a modern Ember app imports directly (`import Component from
// '@glimmer/component'`, `import { tracked } from '@glimmer/tracking'`,
// `import { module, test } from 'qunit'`, etc.) MUST NOT appear in
// the tooling list. The normal import graph already credits them, and
// listing them here would mask a real removal when a user genuinely
// drops the dependency.
let plugin = EmberPlugin;
let deps = plugin.tooling_dependencies();
for name in [
"@glimmer/component",
"@glimmer/tracking",
"@glimmer/env",
"@glint/template",
"@ember/test-helpers",
"ember-qunit",
"qunit",
"qunit-dom",
"ember-data",
"@ember-data/store",
"@ember-data/model",
"@embroider/macros",
"@embroider/router",
"@embroider/test-setup",
// Reached via the normal import graph through `ember-cli-build.js`
// (an entry pattern) which `require()`s the build half, and via
// each v2 addon's `package.json#main` index.js for the shim.
"@embroider/webpack",
"@embroider/vite",
"@embroider/addon-shim",
"ember-load-initializers",
"ember-resolver",
] {
assert!(
!deps.contains(&name),
"{name} is imported from source in modern Ember; remove from tooling_dependencies"
);
}
}

#[test]
fn lifecycle_rules_scope_component_members_to_glimmer_component() {
let rules = EmberPlugin.used_class_member_rules();
let component_rule = rules.iter().find_map(|r| match r {
UsedClassMemberRule::Scoped(s) if s.extends.as_deref() == Some("Component") => Some(s),
_ => None,
});
let component_rule = component_rule.expect("Component-scoped rule missing");
assert!(component_rule.members.iter().any(|m| m == "willDestroy"));
assert!(
component_rule
.members
.iter()
.any(|m| m == "didInsertElement")
);
}

#[test]
fn lifecycle_rules_scope_route_members_to_route_class() {
let rules = EmberPlugin.used_class_member_rules();
let route_rule = rules.iter().find_map(|r| match r {
UsedClassMemberRule::Scoped(s) if s.extends.as_deref() == Some("Route") => Some(s),
_ => None,
});
let route_rule = route_rule.expect("Route-scoped rule missing");
assert!(route_rule.members.iter().any(|m| m == "model"));
assert!(route_rule.members.iter().any(|m| m == "beforeModel"));
assert!(route_rule.members.iter().any(|m| m == "setupController"));
}

#[test]
fn unrelated_classes_get_no_lifecycle_rule_match() {
let rules = EmberPlugin.used_class_member_rules();
for r in &rules {
let UsedClassMemberRule::Scoped(s) = r else {
continue;
};
assert!(!s.matches_heritage(Some("UserService"), &[]));
}
}

#[test]
fn entry_patterns_cover_classic_layout() {
let plugin = EmberPlugin;
let patterns = plugin.entry_patterns();
assert!(patterns.contains(&"app/components/**/*.{js,ts,gjs,gts,hbs}"));
assert!(patterns.contains(&"tests/**/*-test.{js,ts,gjs,gts}"));
}
}
2 changes: 2 additions & 0 deletions crates/core/src/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const RUNTIME_ENTRY_POINT_PLUGINS: &[&str] = &[
"convex",
"docusaurus",
"electron",
"ember",
"expo",
"expo-router",
"gatsby",
Expand Down Expand Up @@ -980,6 +981,7 @@ mod dependency_cruiser;
mod docusaurus;
mod drizzle;
mod electron;
mod ember;
mod eslint;
mod expo;
mod expo_router;
Expand Down
Loading