@wolfcola/eslint-plugin-treeshake 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @wolfcola/eslint-plugin-treeshake
2
+
3
+ An ESLint plugin that flags code patterns known to break tree-shaking. Catches problems at authoring time so they don't reach production bundles.
4
+
5
+ Optionally integrates with [@wolfcola/treeshake-check](https://www.npmjs.com/package/@wolfcola/treeshake-check) for full Rollup-based bundle analysis mapped back to source locations.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add -D @wolfcola/eslint-plugin-treeshake
11
+ ```
12
+
13
+ For bundle-check mode (optional):
14
+
15
+ ```bash
16
+ pnpm add -D @wolfcola/treeshake-check
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ### Flat config (ESLint 9+)
22
+
23
+ ```js
24
+ // eslint.config.mjs
25
+ import treeshake from '@wolfcola/eslint-plugin-treeshake';
26
+
27
+ export default [
28
+ // Use the recommended preset (all static checks, warn severity)
29
+ treeshake.configs.recommended,
30
+
31
+ // Or configure manually:
32
+ {
33
+ plugins: { wolfcola: treeshake },
34
+ rules: {
35
+ 'wolfcola/no-treeshake-hazard': [
36
+ 'warn',
37
+ {
38
+ checkEnums: true,
39
+ checkUnannotatedCalls: true,
40
+ checkPrototypeMutation: true,
41
+ checkGlobalAssignment: true,
42
+ checkCjsPatterns: true,
43
+ checkSideEffectsField: true,
44
+ additionalPureFunctions: [],
45
+ bundleCheck: false,
46
+ },
47
+ ],
48
+ },
49
+ },
50
+ ];
51
+ ```
52
+
53
+ ### Strict preset
54
+
55
+ Enables all static checks at `error` severity and turns on bundle-check mode:
56
+
57
+ ```js
58
+ import treeshake from '@wolfcola/eslint-plugin-treeshake';
59
+
60
+ export default [treeshake.configs.strict];
61
+ ```
62
+
63
+ ## Rule: `wolfcola/no-treeshake-hazard`
64
+
65
+ A single rule covering all tree-shaking hazard categories.
66
+
67
+ ### What it detects
68
+
69
+ | Hazard | What it flags | Autofix |
70
+ | ------------------------- | ------------------------------------------------------------- | ----------------------------- |
71
+ | `EnumPattern` | TypeScript `enum` declarations at module scope | Suggestion: `as const` object |
72
+ | `UnannotatedCall` | Top-level function calls without `/*#__PURE__*/` | Fix: inserts `/*#__PURE__*/` |
73
+ | `PrototypeMutation` | `Object.defineProperty`, `.prototype.x = ...` at module scope | None |
74
+ | `GlobalAssignment` | `window.x = ...`, `globalThis.x = ...` at module scope | None |
75
+ | `CjsPatterns` | `require()`, `module.exports` in ESM files | None |
76
+ | `MissingSideEffectsField` | Missing `"sideEffects"` field in the nearest `package.json` | None |
77
+
78
+ ### Options
79
+
80
+ | Option | Type | Default | Description |
81
+ | ------------------------- | ---------- | ------- | ------------------------------------------------ |
82
+ | `checkEnums` | `boolean` | `true` | Flag TypeScript enums |
83
+ | `checkUnannotatedCalls` | `boolean` | `true` | Flag top-level calls without `/*#__PURE__*/` |
84
+ | `checkPrototypeMutation` | `boolean` | `true` | Flag prototype/property mutations |
85
+ | `checkGlobalAssignment` | `boolean` | `true` | Flag global object assignments |
86
+ | `checkCjsPatterns` | `boolean` | `true` | Flag CommonJS patterns in ESM |
87
+ | `checkSideEffectsField` | `boolean` | `true` | Warn if nearest package.json lacks `sideEffects` |
88
+ | `additionalPureFunctions` | `string[]` | `[]` | Function names to treat as side-effect-free |
89
+ | `bundleCheck` | `boolean` | `false` | Run full Rollup-based analysis (slow) |
90
+ | `bundleCheckCwd` | `string` | auto | Working directory for bundle check |
91
+
92
+ ### Known-pure functions (not flagged)
93
+
94
+ The following top-level calls are recognized as side-effect-free and not flagged by `checkUnannotatedCalls`:
95
+
96
+ `Object.freeze`, `Object.create`, `Object.keys`, `Object.values`, `Object.entries`, `Object.fromEntries`, `Symbol`, `Symbol.for`, `Array.from`, `Array.of`, `Array.isArray`, `Map`, `Set`, `WeakMap`, `WeakSet`, `Number.isNaN`, `Number.isFinite`, `Number.parseInt`, `Number.parseFloat`, `String.fromCharCode`, `String.fromCodePoint`, `JSON.parse`, `JSON.stringify`, `Math.max`, `Math.min`, `Math.floor`, `Math.ceil`, `Math.round`, `Math.abs`, `Promise.resolve`, `Promise.reject`
97
+
98
+ Extend with `additionalPureFunctions`.
99
+
100
+ ### Bundle check mode
101
+
102
+ When `bundleCheck: true`, the rule runs a full Rollup build via `@wolfcola/treeshake-check` and maps results back to source locations. This is slow but catches issues that static analysis misses (transitive side effects, bundler-specific behavior).
103
+
104
+ Bundle-check findings are deduplicated against static findings — if both detect the same hazard category in the same file, only the static finding is reported.
105
+
106
+ Requires `@wolfcola/treeshake-check` as a dev dependency.
107
+
108
+ ### Relationship to @wolfcola/treeshake-check
109
+
110
+ | | eslint-plugin-treeshake | treeshake-check |
111
+ | --------------- | -------------------------------- | ------------------------ |
112
+ | **When** | Authoring time | Post-build / CI |
113
+ | **Speed** | Fast (per-file AST) | Slow (full Rollup build) |
114
+ | **Accuracy** | Heuristic | Ground truth |
115
+ | **Integration** | Editor squiggles, `eslint --fix` | CLI, exit codes |
116
+
117
+ Use both: the ESLint plugin for fast feedback during development, `treeshake-check` as a CI quality gate.
118
+
119
+ ## Examples
120
+
121
+ ### Before (flagged)
122
+
123
+ ```ts
124
+ // Enum - breaks tree-shaking
125
+ export enum Direction {
126
+ Up,
127
+ Down,
128
+ Left,
129
+ Right,
130
+ }
131
+
132
+ // Unannotated call - bundler assumes side effects
133
+ const registry = createRegistry();
134
+
135
+ // Global assignment - observable side effect
136
+ window.MY_APP = { version: '1.0' };
137
+ ```
138
+
139
+ ### After (clean)
140
+
141
+ ```ts
142
+ // as const object - fully shakeable
143
+ export const Direction = {
144
+ Up: 'Up',
145
+ Down: 'Down',
146
+ Left: 'Left',
147
+ Right: 'Right',
148
+ } as const;
149
+ export type Direction = (typeof Direction)[keyof typeof Direction];
150
+
151
+ // PURE annotation - bundler can safely drop if unused
152
+ const registry = /*#__PURE__*/ createRegistry();
153
+
154
+ // Moved into an explicit init function
155
+ export function initApp() {
156
+ window.MY_APP = { version: '1.0' };
157
+ }
158
+ ```
@@ -0,0 +1,5 @@
1
+ import { noTreeshakeHazard } from './lib/no-treeshake-hazard.js';
2
+ declare const plugin: any;
3
+ export default plugin;
4
+ export { noTreeshakeHazard };
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAGjE,QAAA,MAAM,MAAM,EAAE,GASb,CAAC;AAoBF,eAAe,MAAM,CAAC;AACtB,OAAO,EAAE,iBAAiB,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ import { noTreeshakeHazard } from './lib/no-treeshake-hazard.js';
2
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3
+ const plugin = {
4
+ meta: {
5
+ name: '@wolfcola/eslint-plugin-treeshake',
6
+ version: '0.0.0',
7
+ },
8
+ rules: {
9
+ 'no-treeshake-hazard': noTreeshakeHazard,
10
+ },
11
+ configs: {},
12
+ };
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ const recommended = {
15
+ plugins: { wolfcola: plugin },
16
+ rules: {
17
+ 'wolfcola/no-treeshake-hazard': 'warn',
18
+ },
19
+ };
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ const strict = {
22
+ plugins: { wolfcola: plugin },
23
+ rules: {
24
+ 'wolfcola/no-treeshake-hazard': ['error', { bundleCheck: true }],
25
+ },
26
+ };
27
+ plugin.configs = { recommended, strict };
28
+ export default plugin;
29
+ export { noTreeshakeHazard };
@@ -0,0 +1,12 @@
1
+ import type { HazardCategory } from './explanations.js';
2
+ export interface BundleFileReport {
3
+ readonly filePath: string;
4
+ readonly causes: ReadonlyArray<HazardCategory>;
5
+ readonly survivingCode: string | null;
6
+ readonly line: number;
7
+ readonly column: number;
8
+ }
9
+ export declare const loadTreeshakeCheck: () => Promise<any>;
10
+ export declare const mapResultToFileReports: (result: any, sourceContents?: Map<string, string>) => BundleFileReport[];
11
+ export declare const deduplicateReports: (bundleReports: BundleFileReport[], staticFindings: Map<string, Set<HazardCategory>>) => BundleFileReport[];
12
+ //# sourceMappingURL=bundle-check.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bundle-check.d.ts","sourceRoot":"","sources":["../../src/lib/bundle-check.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,cAAc,CAAC,CAAC;IAC/C,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAsBD,eAAO,MAAM,kBAAkB,QAAa,OAAO,CAAC,GAAG,CAWtD,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAEjC,QAAQ,GAAG,EACX,iBAAiB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,KACnC,gBAAgB,EAyBlB,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAC7B,eAAe,gBAAgB,EAAE,EACjC,gBAAgB,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC,KAC/C,gBAAgB,EAWkC,CAAC"}
@@ -0,0 +1,68 @@
1
+ import { findSnippetLocation } from './snippet-match.js';
2
+ const mapCause = (cause) => {
3
+ switch (cause) {
4
+ case 'EnumPattern':
5
+ return 'EnumPattern';
6
+ case 'CommonJsContamination':
7
+ return 'CjsPatterns';
8
+ case 'PrototypeMutation':
9
+ return 'PrototypeMutation';
10
+ case 'GlobalAssignment':
11
+ return 'GlobalAssignment';
12
+ case 'UnannotatedCall':
13
+ return 'UnannotatedCall';
14
+ case 'TopLevelSideEffect':
15
+ return 'TopLevelSideEffect';
16
+ default:
17
+ return 'Unknown';
18
+ }
19
+ };
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ export const loadTreeshakeCheck = async () => {
22
+ // Use a variable to prevent TypeScript from resolving the optional dep at compile time
23
+ const pkg = '@wolfcola/treeshake-check';
24
+ try {
25
+ return await import(pkg);
26
+ }
27
+ catch {
28
+ throw new Error('bundleCheck requires @wolfcola/treeshake-check to be installed. ' +
29
+ 'Run: pnpm add -D @wolfcola/treeshake-check');
30
+ }
31
+ };
32
+ export const mapResultToFileReports = (
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ result, sourceContents) => {
35
+ if (result._tag === 'FullyTreeshakeable')
36
+ return [];
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ return result.modules.map((mod) => {
39
+ const causes = mod.suspectedCauses.map(mapCause);
40
+ let line = 1;
41
+ let column = 0;
42
+ if (mod.survivingCode && sourceContents?.has(mod.id)) {
43
+ const loc = findSnippetLocation(sourceContents.get(mod.id), mod.survivingCode);
44
+ if (loc) {
45
+ line = loc.line;
46
+ column = loc.column;
47
+ }
48
+ }
49
+ return {
50
+ filePath: mod.id,
51
+ causes,
52
+ survivingCode: mod.survivingCode ?? null,
53
+ line,
54
+ column,
55
+ };
56
+ });
57
+ };
58
+ export const deduplicateReports = (bundleReports, staticFindings) => bundleReports
59
+ .map((report) => {
60
+ const staticCauses = staticFindings.get(report.filePath);
61
+ if (!staticCauses)
62
+ return report;
63
+ const remaining = report.causes.filter((c) => !staticCauses.has(c));
64
+ if (remaining.length === 0)
65
+ return null;
66
+ return { ...report, causes: remaining };
67
+ })
68
+ .filter((r) => r !== null);
@@ -0,0 +1,9 @@
1
+ export type HazardCategory = 'EnumPattern' | 'UnannotatedCall' | 'PrototypeMutation' | 'GlobalAssignment' | 'CjsPatterns' | 'TopLevelSideEffect' | 'MissingSideEffectsField' | 'Unknown';
2
+ export interface HazardExplanation {
3
+ readonly messageId: string;
4
+ readonly summary: string;
5
+ readonly why: string;
6
+ readonly fix: string;
7
+ }
8
+ export declare const EXPLANATIONS: Record<HazardCategory, HazardExplanation>;
9
+ //# sourceMappingURL=explanations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"explanations.d.ts","sourceRoot":"","sources":["../../src/lib/explanations.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GACtB,aAAa,GACb,iBAAiB,GACjB,mBAAmB,GACnB,kBAAkB,GAClB,aAAa,GACb,oBAAoB,GACpB,yBAAyB,GACzB,SAAS,CAAC;AAEd,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,cAAc,EAAE,iBAAiB,CAiDlE,CAAC"}
@@ -0,0 +1,50 @@
1
+ export const EXPLANATIONS = {
2
+ EnumPattern: {
3
+ messageId: 'enumPattern',
4
+ summary: 'TypeScript enum breaks tree-shaking.',
5
+ why: 'TypeScript compiles `enum` to an IIFE that mutates a module-scoped variable. Bundlers keep the entire module.',
6
+ fix: 'Replace with an `as const` object and a derived type alias.',
7
+ },
8
+ UnannotatedCall: {
9
+ messageId: 'unannotatedCall',
10
+ summary: 'Top-level function call without /*#__PURE__*/ annotation.',
11
+ why: 'Bundlers treat bare function calls at module scope as side-effectful and cannot eliminate them.',
12
+ fix: 'Add /*#__PURE__*/ before the call if it has no side effects, or move it inside an exported function.',
13
+ },
14
+ PrototypeMutation: {
15
+ messageId: 'prototypeMutation',
16
+ summary: 'Prototype or property mutation at module scope breaks tree-shaking.',
17
+ why: 'Object.defineProperty, Object.assign, or .prototype assignments at the top level are observable side effects.',
18
+ fix: 'Move the mutation inside a function, or annotate with /*#__PURE__*/ if genuinely side-effect-free.',
19
+ },
20
+ GlobalAssignment: {
21
+ messageId: 'globalAssignment',
22
+ summary: 'Assignment to a global object at module scope breaks tree-shaking.',
23
+ why: 'Assignments to window/globalThis/self/global are observable side effects that bundlers can never eliminate.',
24
+ fix: 'Move the assignment into an explicitly-invoked function or a separate entry point.',
25
+ },
26
+ CjsPatterns: {
27
+ messageId: 'cjsPatterns',
28
+ summary: 'CommonJS pattern in an ESM file prevents tree-shaking.',
29
+ why: 'require(), module.exports, and __esModule markers indicate CommonJS, which bundlers cannot statically analyze.',
30
+ fix: 'Use ESM import/export syntax. Ensure your build emits ESM output.',
31
+ },
32
+ TopLevelSideEffect: {
33
+ messageId: 'topLevelSideEffect',
34
+ summary: 'Top-level statement with side effects prevents tree-shaking.',
35
+ why: 'This statement runs when the module is imported and the bundler cannot prove it is safe to remove.',
36
+ fix: 'Move side-effecting code into an exported function, or annotate pure expressions with /*#__PURE__*/.',
37
+ },
38
+ MissingSideEffectsField: {
39
+ messageId: 'missingSideEffectsField',
40
+ summary: 'package.json is missing the "sideEffects" field.',
41
+ why: 'Without "sideEffects": false, bundlers conservatively assume every module may have side effects, blocking aggressive tree-shaking.',
42
+ fix: 'Add "sideEffects": false to package.json. If some files do have side effects, use an array: "sideEffects": ["./src/polyfill.ts"].',
43
+ },
44
+ Unknown: {
45
+ messageId: 'unknown',
46
+ summary: 'Unknown tree-shaking hazard detected by bundle analysis.',
47
+ why: 'The bundler kept this code but no specific pattern was matched.',
48
+ fix: 'Inspect the surviving code manually. Common causes: getters, decorators, destructuring with defaults, class field initializers.',
49
+ },
50
+ };
@@ -0,0 +1,3 @@
1
+ export declare const KNOWN_PURE_CALLS: ReadonlySet<string>;
2
+ export declare const isKnownPure: (calleeName: string, additionalPureFunctions?: ReadonlyArray<string>) => boolean;
3
+ //# sourceMappingURL=known-pure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"known-pure.d.ts","sourceRoot":"","sources":["../../src/lib/known-pure.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAyC/C,CAAC;AAEH,eAAO,MAAM,WAAW,GACtB,YAAY,MAAM,EAClB,0BAAyB,aAAa,CAAC,MAAM,CAAM,KAClD,OAA2F,CAAC"}
@@ -0,0 +1,43 @@
1
+ export const KNOWN_PURE_CALLS = new Set([
2
+ // Object
3
+ 'Object.freeze',
4
+ 'Object.create',
5
+ 'Object.keys',
6
+ 'Object.values',
7
+ 'Object.entries',
8
+ 'Object.fromEntries',
9
+ // Symbol
10
+ 'Symbol',
11
+ 'Symbol.for',
12
+ // Array
13
+ 'Array.from',
14
+ 'Array.of',
15
+ 'Array.isArray',
16
+ // Collections
17
+ 'Map',
18
+ 'Set',
19
+ 'WeakMap',
20
+ 'WeakSet',
21
+ // Number
22
+ 'Number.isNaN',
23
+ 'Number.isFinite',
24
+ 'Number.parseInt',
25
+ 'Number.parseFloat',
26
+ // String
27
+ 'String.fromCharCode',
28
+ 'String.fromCodePoint',
29
+ // JSON
30
+ 'JSON.parse',
31
+ 'JSON.stringify',
32
+ // Math
33
+ 'Math.max',
34
+ 'Math.min',
35
+ 'Math.floor',
36
+ 'Math.ceil',
37
+ 'Math.round',
38
+ 'Math.abs',
39
+ // Promise
40
+ 'Promise.resolve',
41
+ 'Promise.reject',
42
+ ]);
43
+ export const isKnownPure = (calleeName, additionalPureFunctions = []) => KNOWN_PURE_CALLS.has(calleeName) || additionalPureFunctions.includes(calleeName);
@@ -0,0 +1,20 @@
1
+ import { ESLintUtils } from '@typescript-eslint/utils';
2
+ type RuleOptions = [
3
+ {
4
+ checkEnums?: boolean;
5
+ checkUnannotatedCalls?: boolean;
6
+ checkPrototypeMutation?: boolean;
7
+ checkGlobalAssignment?: boolean;
8
+ checkCjsPatterns?: boolean;
9
+ checkSideEffectsField?: boolean;
10
+ additionalPureFunctions?: string[];
11
+ bundleCheck?: boolean;
12
+ bundleCheckCwd?: string;
13
+ }
14
+ ];
15
+ type MessageIds = 'enumPattern' | 'unannotatedCall' | 'prototypeMutation' | 'globalAssignment' | 'cjsPatterns' | 'topLevelSideEffect' | 'missingSideEffectsField' | 'unknown' | 'enumSuggestion';
16
+ export declare const noTreeshakeHazard: ESLintUtils.RuleModule<MessageIds, RuleOptions, unknown, ESLintUtils.RuleListener> & {
17
+ name: string;
18
+ };
19
+ export {};
20
+ //# sourceMappingURL=no-treeshake-hazard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-treeshake-hazard.d.ts","sourceRoot":"","sources":["../../src/lib/no-treeshake-hazard.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,WAAW,EAAiB,MAAM,0BAA0B,CAAC;AAmBtE,KAAK,WAAW,GAAG;IACjB;QACE,UAAU,CAAC,EAAE,OAAO,CAAC;QACrB,qBAAqB,CAAC,EAAE,OAAO,CAAC;QAChC,sBAAsB,CAAC,EAAE,OAAO,CAAC;QACjC,qBAAqB,CAAC,EAAE,OAAO,CAAC;QAChC,gBAAgB,CAAC,EAAE,OAAO,CAAC;QAC3B,qBAAqB,CAAC,EAAE,OAAO,CAAC;QAChC,uBAAuB,CAAC,EAAE,MAAM,EAAE,CAAC;QACnC,WAAW,CAAC,EAAE,OAAO,CAAC;QACtB,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB;CACF,CAAC;AAEF,KAAK,UAAU,GACX,aAAa,GACb,iBAAiB,GACjB,mBAAmB,GACnB,kBAAkB,GAClB,aAAa,GACb,oBAAoB,GACpB,yBAAyB,GACzB,SAAS,GACT,gBAAgB,CAAC;AAOrB,eAAO,MAAM,iBAAiB;;CAqT5B,CAAC"}
@@ -0,0 +1,302 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { ESLintUtils } from '@typescript-eslint/utils';
5
+ import { EXPLANATIONS } from './explanations.js';
6
+ import { isKnownPure } from './known-pure.js';
7
+ import { isModuleScope, getCalleeName, hasPureAnnotation } from './scope-utils.js';
8
+ import { mapResultToFileReports, deduplicateReports } from './bundle-check.js';
9
+ const MUTATION_METHODS = new Set([
10
+ 'Object.defineProperty',
11
+ 'Object.defineProperties',
12
+ 'Object.setPrototypeOf',
13
+ ]);
14
+ const GLOBAL_OBJECTS = new Set(['window', 'globalThis', 'self', 'global']);
15
+ const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/ryanbas21/devtools/blob/main/packages/eslint-plugin-treeshake/docs/rules/${name}.md`);
16
+ const buildMessage = (category) => {
17
+ const e = EXPLANATIONS[category];
18
+ return `${e.summary} ${e.why} ${e.fix}`;
19
+ };
20
+ export const noTreeshakeHazard = createRule({
21
+ name: 'no-treeshake-hazard',
22
+ meta: {
23
+ type: 'problem',
24
+ docs: {
25
+ description: 'Flags code patterns known to break tree-shaking.',
26
+ },
27
+ fixable: 'code',
28
+ hasSuggestions: true,
29
+ schema: [
30
+ {
31
+ type: 'object',
32
+ properties: {
33
+ checkEnums: { type: 'boolean' },
34
+ checkUnannotatedCalls: { type: 'boolean' },
35
+ checkPrototypeMutation: { type: 'boolean' },
36
+ checkGlobalAssignment: { type: 'boolean' },
37
+ checkCjsPatterns: { type: 'boolean' },
38
+ checkSideEffectsField: { type: 'boolean' },
39
+ additionalPureFunctions: {
40
+ type: 'array',
41
+ items: { type: 'string' },
42
+ },
43
+ bundleCheck: { type: 'boolean' },
44
+ bundleCheckCwd: { type: 'string' },
45
+ },
46
+ additionalProperties: false,
47
+ },
48
+ ],
49
+ messages: {
50
+ enumPattern: buildMessage('EnumPattern'),
51
+ unannotatedCall: buildMessage('UnannotatedCall'),
52
+ prototypeMutation: buildMessage('PrototypeMutation'),
53
+ globalAssignment: buildMessage('GlobalAssignment'),
54
+ cjsPatterns: buildMessage('CjsPatterns'),
55
+ topLevelSideEffect: buildMessage('TopLevelSideEffect'),
56
+ missingSideEffectsField: buildMessage('MissingSideEffectsField'),
57
+ unknown: buildMessage('Unknown'),
58
+ enumSuggestion: 'Replace enum with an as const object and type alias.',
59
+ },
60
+ },
61
+ defaultOptions: [
62
+ {
63
+ checkEnums: true,
64
+ checkUnannotatedCalls: true,
65
+ checkPrototypeMutation: true,
66
+ checkGlobalAssignment: true,
67
+ checkCjsPatterns: true,
68
+ checkSideEffectsField: true,
69
+ additionalPureFunctions: [],
70
+ bundleCheck: false,
71
+ bundleCheckCwd: undefined,
72
+ },
73
+ ],
74
+ create(context, [options]) {
75
+ const { checkEnums = true, checkUnannotatedCalls = true, checkPrototypeMutation = true, checkGlobalAssignment = true, checkCjsPatterns = true, checkSideEffectsField = true, additionalPureFunctions = [], } = options;
76
+ const sourceCode = context.sourceCode;
77
+ // Track static findings for dedup with bundle check
78
+ const staticFindings = new Map();
79
+ const recordStatic = (category) => {
80
+ const file = context.filename;
81
+ if (!staticFindings.has(file))
82
+ staticFindings.set(file, new Set());
83
+ staticFindings.get(file).add(category);
84
+ };
85
+ /**
86
+ * Build the replacement text for an enum → as const object suggestion.
87
+ */
88
+ function buildEnumReplacement(node, isExported) {
89
+ const name = node.id.name;
90
+ const members = (node.body?.members ?? node.members)
91
+ .map((m) => {
92
+ const key = m.id.type === 'Identifier' ? m.id.name : sourceCode.getText(m.id);
93
+ const value = m.initializer ? sourceCode.getText(m.initializer) : `"${key}"`;
94
+ return ` ${key}: ${value}`;
95
+ })
96
+ .join(',\n');
97
+ const exportPrefix = isExported ? 'export ' : '';
98
+ const obj = `${exportPrefix}const ${name} = {\n${members},\n} as const;`;
99
+ const type = `${exportPrefix}type ${name} = (typeof ${name})[keyof typeof ${name}];`;
100
+ return `${obj}\n${type}`;
101
+ }
102
+ return {
103
+ // 1. TSEnumDeclaration
104
+ TSEnumDeclaration(node) {
105
+ if (!checkEnums || !isModuleScope(node))
106
+ return;
107
+ const isExported = node.parent?.type === 'ExportNamedDeclaration';
108
+ const reportNode = isExported ? node.parent : node;
109
+ recordStatic('EnumPattern');
110
+ context.report({
111
+ node,
112
+ messageId: 'enumPattern',
113
+ suggest: [
114
+ {
115
+ messageId: 'enumSuggestion',
116
+ fix(fixer) {
117
+ return fixer.replaceText(reportNode, buildEnumReplacement(node, isExported));
118
+ },
119
+ },
120
+ ],
121
+ });
122
+ },
123
+ // 2. CallExpression — unannotated top-level calls
124
+ CallExpression(node) {
125
+ if (!checkUnannotatedCalls || !isModuleScope(node))
126
+ return;
127
+ const calleeName = getCalleeName(node.callee);
128
+ if (calleeName && isKnownPure(calleeName, additionalPureFunctions))
129
+ return;
130
+ if (calleeName && MUTATION_METHODS.has(calleeName))
131
+ return;
132
+ if (hasPureAnnotation(sourceCode, node))
133
+ return;
134
+ // Skip require() calls — handled by CJS check
135
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require')
136
+ return;
137
+ recordStatic('UnannotatedCall');
138
+ context.report({
139
+ node,
140
+ messageId: 'unannotatedCall',
141
+ fix(fixer) {
142
+ return fixer.insertTextBefore(node, '/*#__PURE__*/ ');
143
+ },
144
+ });
145
+ },
146
+ // 3. Prototype mutation — method calls (Object.defineProperty, etc.)
147
+ 'ExpressionStatement > CallExpression'(node) {
148
+ if (!checkPrototypeMutation || !isModuleScope(node))
149
+ return;
150
+ const calleeName = getCalleeName(node.callee);
151
+ if (calleeName && MUTATION_METHODS.has(calleeName)) {
152
+ recordStatic('PrototypeMutation');
153
+ context.report({
154
+ node,
155
+ messageId: 'prototypeMutation',
156
+ });
157
+ }
158
+ },
159
+ // 4. Prototype mutation — assignment (X.prototype.y = ...)
160
+ 'ExpressionStatement > AssignmentExpression'(node) {
161
+ if (!checkPrototypeMutation || !isModuleScope(node))
162
+ return;
163
+ if (node.left.type === 'MemberExpression' &&
164
+ node.left.object.type === 'MemberExpression' &&
165
+ !node.left.object.computed &&
166
+ node.left.object.property.type === 'Identifier' &&
167
+ node.left.object.property.name === 'prototype') {
168
+ recordStatic('PrototypeMutation');
169
+ context.report({
170
+ node,
171
+ messageId: 'prototypeMutation',
172
+ });
173
+ }
174
+ },
175
+ // 5. Global assignment
176
+ AssignmentExpression(node) {
177
+ if (!checkGlobalAssignment || !isModuleScope(node))
178
+ return;
179
+ if (node.left.type === 'MemberExpression' &&
180
+ node.left.object.type === 'Identifier' &&
181
+ GLOBAL_OBJECTS.has(node.left.object.name)) {
182
+ recordStatic('GlobalAssignment');
183
+ context.report({
184
+ node,
185
+ messageId: 'globalAssignment',
186
+ });
187
+ }
188
+ },
189
+ // 6. CJS — require()
190
+ 'CallExpression[callee.name="require"]'(node) {
191
+ if (!checkCjsPatterns || !isModuleScope(node))
192
+ return;
193
+ context.report({
194
+ node,
195
+ messageId: 'cjsPatterns',
196
+ });
197
+ },
198
+ // 7. CJS — module.exports
199
+ 'MemberExpression[object.name="module"][property.name="exports"]'(node) {
200
+ if (!checkCjsPatterns || !isModuleScope(node))
201
+ return;
202
+ context.report({
203
+ node,
204
+ messageId: 'cjsPatterns',
205
+ });
206
+ },
207
+ // 8. CJS — exports.x (skip if parent is module.exports)
208
+ 'MemberExpression[object.name="exports"]'(node) {
209
+ if (!checkCjsPatterns || !isModuleScope(node))
210
+ return;
211
+ // Skip if this is module.exports (avoid double report)
212
+ if (node.parent?.type === 'MemberExpression' && node.parent.object === node) {
213
+ return;
214
+ }
215
+ context.report({
216
+ node,
217
+ messageId: 'cjsPatterns',
218
+ });
219
+ },
220
+ // 9. Check nearest package.json for missing sideEffects field
221
+ Program(node) {
222
+ if (!checkSideEffectsField)
223
+ return;
224
+ let dir = dirname(context.filename);
225
+ for (let i = 0; i < 20; i++) {
226
+ const candidate = join(dir, 'package.json');
227
+ if (existsSync(candidate)) {
228
+ try {
229
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
230
+ if (pkg.sideEffects === undefined) {
231
+ context.report({
232
+ node,
233
+ loc: { line: 1, column: 0 },
234
+ messageId: 'missingSideEffectsField',
235
+ });
236
+ }
237
+ }
238
+ catch {
239
+ // Ignore parse errors in package.json
240
+ }
241
+ break;
242
+ }
243
+ const parent = dirname(dir);
244
+ if (parent === dir)
245
+ break;
246
+ dir = parent;
247
+ }
248
+ },
249
+ // 10. Bundle check (opt-in, synchronous via execFileSync)
250
+ 'Program:exit'(node) {
251
+ if (!options.bundleCheck)
252
+ return;
253
+ const cwd = options.bundleCheckCwd ?? process.cwd();
254
+ let jsonOutput;
255
+ try {
256
+ jsonOutput = execFileSync('npx', ['treeshake-check', '--json'], {
257
+ cwd,
258
+ encoding: 'utf-8',
259
+ timeout: 60_000,
260
+ stdio: ['pipe', 'pipe', 'pipe'],
261
+ });
262
+ }
263
+ catch (err) {
264
+ // treeshake-check exits 1 when not shakeable — stdout still has JSON
265
+ const execErr = err;
266
+ if (execErr.stdout) {
267
+ jsonOutput = execErr.stdout;
268
+ }
269
+ else {
270
+ context.report({
271
+ node,
272
+ loc: { line: 1, column: 0 },
273
+ messageId: 'unknown',
274
+ });
275
+ return;
276
+ }
277
+ }
278
+ let result;
279
+ try {
280
+ result = JSON.parse(jsonOutput);
281
+ }
282
+ catch {
283
+ return;
284
+ }
285
+ const reports = mapResultToFileReports(result);
286
+ const deduplicated = deduplicateReports(reports, staticFindings);
287
+ for (const report of deduplicated) {
288
+ if (report.filePath !== context.filename)
289
+ continue;
290
+ for (const cause of report.causes) {
291
+ const explanation = EXPLANATIONS[cause];
292
+ context.report({
293
+ node,
294
+ loc: { line: report.line, column: report.column },
295
+ messageId: explanation.messageId,
296
+ });
297
+ }
298
+ }
299
+ },
300
+ };
301
+ },
302
+ });
@@ -0,0 +1,20 @@
1
+ import type { TSESTree } from '@typescript-eslint/utils';
2
+ /**
3
+ * Check whether a node is at the top level of a module (direct child of Program.body).
4
+ * Walk up the parent chain; if we hit Program without crossing a function/class boundary,
5
+ * the node is at module scope.
6
+ */
7
+ export declare const isModuleScope: (node: TSESTree.Node) => boolean;
8
+ /**
9
+ * Extract a human-readable callee name from a CallExpression's callee node.
10
+ * Returns "Object.freeze" for `Object.freeze(...)`, "foo" for `foo(...)`,
11
+ * or null for computed/complex expressions.
12
+ */
13
+ export declare const getCalleeName: (callee: TSESTree.Expression) => string | null;
14
+ /**
15
+ * Check whether a node has a leading /*#__PURE__*\/ comment.
16
+ */
17
+ export declare const hasPureAnnotation: (sourceCode: {
18
+ getCommentsBefore(node: TSESTree.Node): TSESTree.Comment[];
19
+ }, node: TSESTree.Node) => boolean;
20
+ //# sourceMappingURL=scope-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-utils.d.ts","sourceRoot":"","sources":["../../src/lib/scope-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAEzD;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,QAAQ,CAAC,IAAI,KAAG,OAgBnD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,QAAQ,QAAQ,CAAC,UAAU,KAAG,MAAM,GAAG,IAapE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,GAC5B,YAAY;IAAE,iBAAiB,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,GAAG,QAAQ,CAAC,OAAO,EAAE,CAAA;CAAE,EAC1E,MAAM,QAAQ,CAAC,IAAI,KAClB,OAGF,CAAC"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Check whether a node is at the top level of a module (direct child of Program.body).
3
+ * Walk up the parent chain; if we hit Program without crossing a function/class boundary,
4
+ * the node is at module scope.
5
+ */
6
+ export const isModuleScope = (node) => {
7
+ let current = node.parent;
8
+ while (current) {
9
+ switch (current.type) {
10
+ case 'Program':
11
+ return true;
12
+ case 'FunctionDeclaration':
13
+ case 'FunctionExpression':
14
+ case 'ArrowFunctionExpression':
15
+ case 'ClassBody':
16
+ case 'StaticBlock':
17
+ return false;
18
+ }
19
+ current = current.parent;
20
+ }
21
+ return false;
22
+ };
23
+ /**
24
+ * Extract a human-readable callee name from a CallExpression's callee node.
25
+ * Returns "Object.freeze" for `Object.freeze(...)`, "foo" for `foo(...)`,
26
+ * or null for computed/complex expressions.
27
+ */
28
+ export const getCalleeName = (callee) => {
29
+ if (callee.type === 'Identifier') {
30
+ return callee.name;
31
+ }
32
+ if (callee.type === 'MemberExpression' &&
33
+ !callee.computed &&
34
+ callee.object.type === 'Identifier' &&
35
+ callee.property.type === 'Identifier') {
36
+ return `${callee.object.name}.${callee.property.name}`;
37
+ }
38
+ return null;
39
+ };
40
+ /**
41
+ * Check whether a node has a leading /*#__PURE__*\/ comment.
42
+ */
43
+ export const hasPureAnnotation = (sourceCode, node) => {
44
+ const comments = sourceCode.getCommentsBefore(node);
45
+ return comments.some((c) => c.type === 'Block' && c.value.trim() === '#__PURE__');
46
+ };
@@ -0,0 +1,13 @@
1
+ export interface SourceLocation {
2
+ /** 1-based line number */
3
+ readonly line: number;
4
+ /** 0-based column offset */
5
+ readonly column: number;
6
+ }
7
+ /**
8
+ * Find where a code snippet appears in a source string.
9
+ * For multi-line snippets, matches the first non-empty line of the snippet.
10
+ * Returns null if not found or snippet is empty/whitespace.
11
+ */
12
+ export declare const findSnippetLocation: (source: string, snippet: string) => SourceLocation | null;
13
+ //# sourceMappingURL=snippet-match.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snippet-match.d.ts","sourceRoot":"","sources":["../../src/lib/snippet-match.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC7B,0BAA0B;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,4BAA4B;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,KAAG,cAAc,GAAG,IAiBtF,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Find where a code snippet appears in a source string.
3
+ * For multi-line snippets, matches the first non-empty line of the snippet.
4
+ * Returns null if not found or snippet is empty/whitespace.
5
+ */
6
+ export const findSnippetLocation = (source, snippet) => {
7
+ const trimmed = snippet.trim();
8
+ if (trimmed.length === 0)
9
+ return null;
10
+ const firstLine = trimmed.split('\n').find((l) => l.trim().length > 0);
11
+ if (!firstLine)
12
+ return null;
13
+ const searchTarget = firstLine.trim();
14
+ const index = source.indexOf(searchTarget);
15
+ if (index === -1)
16
+ return null;
17
+ const before = source.slice(0, index);
18
+ const line = before.split('\n').length;
19
+ const lastNewline = before.lastIndexOf('\n');
20
+ const column = lastNewline === -1 ? index : index - lastNewline - 1;
21
+ return { line, column };
22
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@wolfcola/eslint-plugin-treeshake",
3
+ "version": "0.0.0",
4
+ "description": "ESLint plugin that flags code patterns known to break tree-shaking",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/ryanbas21/devtools.git",
10
+ "directory": "packages/eslint-plugin-treeshake"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./package.json": "./package.json"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "files": [
26
+ "dist",
27
+ "!dist/*.tsbuildinfo"
28
+ ],
29
+ "dependencies": {
30
+ "@typescript-eslint/utils": "8.59.2"
31
+ },
32
+ "peerDependencies": {
33
+ "eslint": ">=9.0.0",
34
+ "@typescript-eslint/parser": ">=8.0.0"
35
+ },
36
+ "optionalDependencies": {
37
+ "@wolfcola/treeshake-check": "0.0.0"
38
+ },
39
+ "devDependencies": {
40
+ "@typescript-eslint/rule-tester": "8.59.2",
41
+ "vitest": "^3.2.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc -p tsconfig.lib.json",
45
+ "lint": "eslint .",
46
+ "test": "vitest run"
47
+ }
48
+ }