@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 +158 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/lib/bundle-check.d.ts +12 -0
- package/dist/lib/bundle-check.d.ts.map +1 -0
- package/dist/lib/bundle-check.js +68 -0
- package/dist/lib/explanations.d.ts +9 -0
- package/dist/lib/explanations.d.ts.map +1 -0
- package/dist/lib/explanations.js +50 -0
- package/dist/lib/known-pure.d.ts +3 -0
- package/dist/lib/known-pure.d.ts.map +1 -0
- package/dist/lib/known-pure.js +43 -0
- package/dist/lib/no-treeshake-hazard.d.ts +20 -0
- package/dist/lib/no-treeshake-hazard.d.ts.map +1 -0
- package/dist/lib/no-treeshake-hazard.js +302 -0
- package/dist/lib/scope-utils.d.ts +20 -0
- package/dist/lib/scope-utils.d.ts.map +1 -0
- package/dist/lib/scope-utils.js +46 -0
- package/dist/lib/snippet-match.d.ts +13 -0
- package/dist/lib/snippet-match.d.ts.map +1 -0
- package/dist/lib/snippet-match.js +22 -0
- package/package.json +48 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|