@zgeoff/format-codemod 0.0.4 → 0.0.6

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 CHANGED
@@ -1,106 +1,119 @@
1
1
  # @zgeoff/format-codemod
2
2
 
3
- Enforce a small set of blank-line padding conventions in TypeScript via a fast text-splice codemod.
4
- It parses each file once with [oxc-parser](https://oxc.rs) (native, so parsing is effectively free),
5
- plans positional whitespace splices from node and comment spans, and skips type-aware analysis, so
6
- it runs far faster than a full lint pass — fast enough to sit in a pre-commit hook or a `format`
7
- pipeline without being noticed.
3
+ Machine-owned vertical spacing for TypeScript. Every gap between adjacent statements is normalized
4
+ to exactly one blank line where a rule below requires one, and zero where none does — a superset of
5
+ ESLint's `padding-line-between-statements` and `@stylistic/lines-between-class-members`, run as a
6
+ text-splice codemod. Each file is parsed once with [oxc-parser](https://oxc.rs) (native) and edited
7
+ by positional whitespace splices — no type-aware analysis — so a pass is fast enough for a
8
+ pre-commit hook or a `format` pipeline. Runs are idempotent: a pass over compliant code is a no-op.
8
9
 
9
- ## Rules implemented
10
+ Three kinds of gap are left alone:
10
11
 
11
- Mirrors a subset of ESLint's `padding-line-between-statements` plus
12
- `@stylistic/lines-between-class-members`. Every rule is "always exactly one blank line"; there are
13
- no "never" rules.
12
+ - A gap holding any comment is never collapsed, only grown — prose between statements marks
13
+ intentional grouping the rules can't see.
14
+ - The interior of an import block is never touched — it belongs to the import sorter. The boundary
15
+ after the last import is still padded.
16
+ - Two statements sharing a physical line (a leading-semicolon `;(expr)` guard) are skipped — a
17
+ blank-only edit can't separate them without orphaning a token.
14
18
 
15
- **`padding-line-between-statements`** — insert one blank line:
19
+ ## Rules
16
20
 
17
- - after a `const`/`let`/`var` block, before any non-var statement
18
- - before any `return`
21
+ **Padded on both sides** — the statement is its own paragraph:
22
+
23
+ - a control-flow block: `if`, `for`, `for…in`, `for…of`, `while`, `switch`, `try`
24
+ - any statement that spans multiple lines
25
+ - a type alias or interface declaration — runs of them are padded apart, not glued. This is the one
26
+ rule that looks through `export`: `export type X = …` pads like `type X = …`.
27
+
28
+ **Padded on one side:**
29
+
30
+ - before every `return`
19
31
  - after a `function` or `class` declaration
20
- - on both sides of a control-flow block: `if`, `for`, `for…in`, `for…of`, `while`, `switch`, `try`
21
- - on both sides of any statement that spans multiple lines
22
- - at the boundary between statement kinds — a `const`/`let`/`var` declaration, a
23
- `using`/`await using` declaration, a call statement, an assignment/increment statement — in any
24
- order; runs of one kind stay tight
25
- - after a `using`/`await using` block, before any statement of another kind
26
- - at the boundary between an instantiation-headed statement (`new` at the head of its expression
27
- chain, as in `new X(…)` or `new X(…).method()`) and any other statement kind — `new` in argument
28
- position doesn't count, and runs of instantiations stay glued
29
- - at the boundary between an awaited and a non-awaited statement — awaited means an `await using`
30
- declaration, or an `await` in the head of the statement's value chain (`await f()`,
31
- `const x = await f()`, `x = (await f()).prop`); an `await` in argument position (`use(await f())`)
32
- or under a non-chain head (`flag ? await f() : g()`) doesn't count, and runs of awaited statements
33
- stay glued
34
-
35
- **`@stylistic/lines-between-class-members`** — one blank line between class members.
36
-
37
- ### Notes
38
-
39
- - **Consecutive single-line `const`/`let`/`var` declarations stay glued** (no blank between them); a
40
- multiline declaration is separated like any other multiline statement.
41
- - **Bare declarations only.** `export const`, `export function`, and `export class` are not matched
42
- — ESLint's selectors do not look through `export`, and neither does this. `using`/`await using`
43
- declarations are not var declarations; they form their own statement kind.
44
- - **The `using` and awaited-boundary rules go beyond ESLint** — `padding-line-between-statements`
45
- cannot express either, so on these the codemod is the spec.
46
- - **`.d.ts` files are skipped.**
32
+ - after a directive prologue (`'use strict'`, `'use client'`); runs of directives stay glued
33
+ - after the last import of a block
34
+
35
+ **Kind boundaries** — statements classify into kinds; a run of one kind sits flush, and the boundary
36
+ between two kinds takes a blank line:
37
+
38
+ - `const`/`let`/`var` declarations
39
+ - `using`/`await using` declarations
40
+ - bare calls and method calls — two distinct kinds, split by the first call in reading order:
41
+ `expect(x).toBe(y)` opens with a bare function, `fs.writeFileSync(…)` opens with a member, and a
42
+ member call chained onto a bare call's result is still a bare call
43
+ - assignments and increments
44
+
45
+ Two axes overlay the kinds and pad their own boundaries:
46
+
47
+ - instantiation — a statement headed by `new` (`new X(…)`, `new X(…).method()`) is separated from
48
+ any non-instantiation statement; `new` in argument position doesn't count, and runs of
49
+ instantiations stay glued
50
+ - awaitedness — an awaited statement (`await using`, or an `await` in the head of the statement's
51
+ value chain: `await f()`, `const x = await f()`) is separated from non-awaited ones; an `await` in
52
+ argument position (`use(await f())`) or under a non-chain head (`flag ? await f() : g()`) doesn't
53
+ count, and runs of awaited statements stay glued
54
+
55
+ **Class members** — one blank line between members.
56
+
57
+ Only bare declarations match the declaration rules: `export const`, `export function`, and
58
+ `export class` are not declarations to the classifier (type aliases and interfaces are the one
59
+ exception, above).
60
+
61
+ Comment positions come from the parser, not lexical scanning, so comment-lookalike text can't
62
+ mislead the classification. A trailing same-line comment stays attached to the statement before it —
63
+ the blank line goes after the comment; a comment on its own line attaches to the statement after it
64
+ — the blank line goes before. The next statement's indentation is preserved.
47
65
 
48
66
  ## CLI
49
67
 
50
68
  ```bash
51
69
  format-codemod [options] <file|dir|glob> ...
52
70
 
53
- --check exit 1 if any file would change; do not write
54
- --dry print unified diff to stdout; do not write
55
- --bench print parse stats as JSON to stderr
56
- --quiet only print files that would change
71
+ --check exit 1 if any file would change; do not write
72
+ --dry print unified diff to stdout; do not write
73
+ --bench print parse stats as JSON to stderr
74
+ --quiet only print files that would change
75
+ --ignore <glob> skip files matching the glob (repeatable)
57
76
  --version
58
77
  --help
59
78
  ```
60
79
 
61
- Flags are strict: an unknown flag is a usage error (exit 2), never a silent no-op — the default mode
62
- writes files, so a typo'd `--check` must not fall through to a rewrite.
63
-
64
- ### Examples
80
+ Flags are strict: an unknown flag is a usage error, never a silent no-op — the default mode writes
81
+ files, so a typo'd `--check` must not fall through to a rewrite. Exit codes: `0` clean, `1` files
82
+ would change (`--check`), `2` usage error or one or more files failed (parse error, unreadable,
83
+ unwritable).
65
84
 
66
85
  ```bash
67
86
  format-codemod 'src/**/*.{ts,tsx}' # rewrite in place
68
87
  format-codemod --check 'src/**/*.{ts,tsx}' # CI gate
69
- format-codemod --dry path/to/file.ts # preview diff
88
+ format-codemod --dry path/to/file.ts # preview diff; applies cleanly with patch(1)
70
89
  format-codemod . # whole repo, in place
71
90
  ```
72
91
 
73
- Globs use Node 22's built-in `fs.glob`. A literal path is resolved as a file before the glob
74
- heuristic runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are handled correctly
75
- rather than read as glob character classes. A directory argument expands to `**/*.{ts,tsx}` beneath
76
- it; `node_modules` and `.git` are never descended into, and overlapping patterns are deduped so a
77
- file is processed once.
78
-
79
- `--dry` prints a real unified diff — `@@` hunk headers with three lines of context — that applies
80
- cleanly with `patch(1)`.
92
+ ### Inputs
81
93
 
82
- ## Dialect handling
94
+ Globs use Node's built-in `fs.glob`. A literal path is resolved as a file before the glob heuristic
95
+ runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are read as files, not glob
96
+ character classes. A directory expands to `**/*.{ts,tsx}` beneath it; `node_modules` and `.git` are
97
+ never descended into, and overlapping patterns are deduped so each file is processed once. `.d.ts`
98
+ files are skipped. The extension picks the parse dialect: `.tsx` enables JSX, everything else parses
99
+ as plain TypeScript — so `.ts`-only syntax (`<string>value` assertions, un-comma'd generic arrows
100
+ `<T>(a: T) => a`) parses correctly instead of being misread as JSX.
83
101
 
84
- The filename picks the parse dialect: `.tsx` enables JSX, everything else parses as plain
85
- TypeScript. That means `.ts`-only syntax — old-style type assertions (`<string>value`), un-comma'd
86
- generic arrows (`<T>(a: T) => a`) — parses correctly instead of being misread as JSX.
102
+ ### Ignoring files
87
103
 
88
- ## Trivia handling
104
+ A `.formatignore` file in the working directory supplies ignore globs — one per line, blank lines
105
+ and `#`-prefixed comment lines skipped:
89
106
 
90
- Comment positions come from the parser, not lexical scanning:
91
-
92
- - Trailing same-line comments stay attached to the previous statement: `const X = 1; // note` keeps
93
- the blank line _after_ the comment (several same-line comments all stay together).
94
- - Leading comments stay attached to the next statement: the blank line goes _before_ the comment.
95
- - Indentation of the next statement is preserved.
96
- - Two statements that share a physical line are left untouched — for example a leading-semicolon
97
- `;(expr)` whose `;` terminates the previous statement. A blank-only edit cannot separate them
98
- without orphaning a token, so the codemod skips the pair.
99
-
100
- ## Idempotent
107
+ ```gitignore
108
+ # committed codegen
109
+ src/generated/**
110
+ dist/**
111
+ ```
101
112
 
102
- Running the codemod twice produces the same output as running it once. A run on already-compliant
103
- code is a no-op.
113
+ Each line uses the same semantics as `--ignore` (Node's `path.matchesGlob`, tried against both the
114
+ expanded path and the path relative to the working directory). It is not a gitignore dialect: `!`
115
+ negation and directory-anchoring rules are not supported. `--ignore` flags merge additively with the
116
+ file's globs.
104
117
 
105
118
  ## Programmatic use
106
119
 
@@ -112,5 +125,5 @@ const { output, edits, parseError } = transform(source, { filename: 'component.t
112
125
  // parseError: error message if the source could not be parsed; source returned untouched
113
126
  ```
114
127
 
115
- `filename` is optional and only drives dialect selection (default `'source.ts'` — plain TypeScript,
116
- no JSX); nothing is read from disk.
128
+ `filename` is optional and only picks the dialect (default `'source.ts'` — plain TypeScript, no
129
+ JSX); nothing is read from disk.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Reads `.formatignore` from the given directory: one glob per line, blank
3
+ * lines and `#`-prefixed comment lines skipped. Lines share --ignore's glob
4
+ * semantics — this is not a gitignore dialect, so `!` negation and directory
5
+ * anchoring don't apply. A missing file yields an empty list, so callers
6
+ * merge unconditionally.
7
+ */
8
+ export declare function loadFormatIgnore(dir: string): readonly string[];
package/dist/cli.js CHANGED
@@ -49,6 +49,18 @@ function isIgnored(file, patterns) {
49
49
  return patterns.some((pattern) => path.matchesGlob(file, pattern) || path.matchesGlob(relative, pattern));
50
50
  }
51
51
 
52
+ // src/cli/load-format-ignore.ts
53
+ import fs2 from "node:fs";
54
+ import path2 from "node:path";
55
+ function loadFormatIgnore(dir) {
56
+ const file = path2.join(dir, ".formatignore");
57
+ if (!fs2.existsSync(file)) {
58
+ return [];
59
+ }
60
+ return fs2.readFileSync(file, "utf8").split(`
61
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
62
+ }
63
+
52
64
  // src/cli/parse-cli-args.ts
53
65
  import { parseArgs } from "node:util";
54
66
  function parseCLIArgs(argv) {
@@ -103,6 +115,11 @@ options:
103
115
  --version
104
116
  --help
105
117
 
118
+ files:
119
+ .formatignore read from the working directory when present: one glob per
120
+ line, blank lines and # comments skipped, merged with
121
+ --ignore
122
+
106
123
  exit codes:
107
124
  0 clean
108
125
  1 files would change (--check)
@@ -133,9 +150,9 @@ function printReport(file, report, quiet) {
133
150
  }
134
151
 
135
152
  // src/cli/read-package-version.ts
136
- import fs2 from "node:fs";
153
+ import fs3 from "node:fs";
137
154
  function readPackageVersion(pkgPath) {
138
- const parsed = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
155
+ const parsed = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
139
156
  if (!isPackageJSON(parsed)) {
140
157
  throw new TypeError("Invalid package.json");
141
158
  }
@@ -146,7 +163,7 @@ function isPackageJSON(value) {
146
163
  }
147
164
 
148
165
  // src/cli/check-file.ts
149
- import fs4 from "node:fs";
166
+ import fs5 from "node:fs";
150
167
 
151
168
  // src/transform/apply-edits.ts
152
169
  function applyEdits(src, edits) {
@@ -190,6 +207,27 @@ function collectChildNodes(node) {
190
207
  return children;
191
208
  }
192
209
 
210
+ // src/transform/collect-head-chain.ts
211
+ var HEAD_PROPERTY = {
212
+ CallExpression: "callee",
213
+ MemberExpression: "object",
214
+ ChainExpression: "expression",
215
+ AwaitExpression: "argument",
216
+ TSNonNullExpression: "expression",
217
+ ParenthesizedExpression: "expression"
218
+ };
219
+ function collectHeadChain(expression) {
220
+ const chain = [];
221
+ let current = expression;
222
+ while (current !== undefined) {
223
+ chain.push(current);
224
+ const property = HEAD_PROPERTY[current.type];
225
+ const inner = property === undefined ? undefined : current[property];
226
+ current = isASTNode(inner) ? inner : undefined;
227
+ }
228
+ return chain;
229
+ }
230
+
193
231
  // src/transform/needs-blank-line.ts
194
232
  var CONTROL_FLOW_TYPES = new Set([
195
233
  "IfStatement",
@@ -204,19 +242,30 @@ function needsBlankLine(container, prev, next) {
204
242
  if (container.type === "ClassBody") {
205
243
  return true;
206
244
  }
207
- if (isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next)) {
245
+ if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
208
246
  return true;
209
247
  }
210
- if (next.type === "ReturnStatement") {
248
+ if (isTypeDecl(prev) || isTypeDecl(next)) {
211
249
  return true;
212
250
  }
213
- if (isFnOrClassDecl(prev)) {
251
+ return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
252
+ }
253
+ function isRunEnd(prev, next) {
254
+ return isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next) || isDirective(prev) && !isDirective(next) || prev.type === "ImportDeclaration" && next.type !== "ImportDeclaration";
255
+ }
256
+ function isDirective(node) {
257
+ return typeof node["directive"] === "string";
258
+ }
259
+ var TYPE_DECL_TYPES = new Set(["TSTypeAliasDeclaration", "TSInterfaceDeclaration"]);
260
+ function isTypeDecl(node) {
261
+ if (TYPE_DECL_TYPES.has(node.type)) {
214
262
  return true;
215
263
  }
216
- return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
264
+ const declaration = node.declaration;
265
+ return node.type === "ExportNamedDeclaration" && isASTNode(declaration) && TYPE_DECL_TYPES.has(declaration.type);
217
266
  }
218
267
  function isNewHeaded(node) {
219
- return getStatementValues(node).some((value) => getExpressionHead(value).type === "NewExpression");
268
+ return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
220
269
  }
221
270
  function getStatementValues(node) {
222
271
  if (node.type === "VariableDeclaration") {
@@ -232,44 +281,11 @@ function getStatementValues(node) {
232
281
  }
233
282
  return [expression];
234
283
  }
235
- var HEAD_PROPERTY = {
236
- CallExpression: "callee",
237
- MemberExpression: "object",
238
- ChainExpression: "expression",
239
- AwaitExpression: "argument",
240
- TSNonNullExpression: "expression",
241
- ParenthesizedExpression: "expression"
242
- };
243
- function getExpressionHead(node) {
244
- let current = node;
245
- let property = HEAD_PROPERTY[current.type];
246
- while (property !== undefined) {
247
- const inner = current[property];
248
- if (!isASTNode(inner)) {
249
- break;
250
- }
251
- current = inner;
252
- property = HEAD_PROPERTY[current.type];
253
- }
254
- return current;
255
- }
256
284
  function isAwaitHeaded(node) {
257
285
  if (node.type === "VariableDeclaration" && node.kind === "await using") {
258
286
  return true;
259
287
  }
260
- return getStatementValues(node).some((value) => hasAwaitInHeadChain(value));
261
- }
262
- function hasAwaitInHeadChain(node) {
263
- let current = node;
264
- while (current !== undefined) {
265
- if (current.type === "AwaitExpression") {
266
- return true;
267
- }
268
- const property = HEAD_PROPERTY[current.type];
269
- const inner = property === undefined ? undefined : current[property];
270
- current = isASTNode(inner) ? inner : undefined;
271
- }
272
- return false;
288
+ return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
273
289
  }
274
290
  var CALL_TYPES = ["CallExpression", "AwaitExpression"];
275
291
  var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
@@ -286,10 +302,20 @@ function getStatementKind(node) {
286
302
  return "declaration";
287
303
  }
288
304
  if (isExpressionStatementOf(node, CALL_TYPES)) {
289
- return "call";
305
+ return pickCallKind(node);
290
306
  }
291
307
  return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
292
308
  }
309
+ function pickCallKind(node) {
310
+ const expression = node["expression"];
311
+ const callee = isASTNode(expression) ? findDeepestCallee(expression) : null;
312
+ return callee?.type === "MemberExpression" ? "method-call" : "bare-call";
313
+ }
314
+ function findDeepestCallee(expression) {
315
+ const deepestCall = collectHeadChain(expression).findLast((link) => link.type === "CallExpression");
316
+ const callee = deepestCall?.["callee"];
317
+ return isASTNode(callee) ? callee : null;
318
+ }
293
319
  function isExpressionStatementOf(node, types) {
294
320
  const expression = node["expression"];
295
321
  return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
@@ -313,12 +339,12 @@ function isFnOrClassDecl(node) {
313
339
  }
314
340
 
315
341
  // src/transform/plan-gap-edit.ts
316
- function planGapEdit(file, prev, next) {
317
- const gap = buildGap(file, prev, next);
342
+ function planGapEdit(input) {
343
+ const gap = buildGap(input.file, input.prev, input.next);
318
344
  if (!isSafeToResize(gap)) {
319
345
  return null;
320
346
  }
321
- return planBlankLineEdit(gap);
347
+ return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
322
348
  }
323
349
  function buildGap(file, prev, next) {
324
350
  const start = getNodeEnd(prev);
@@ -400,6 +426,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
400
426
  function countNewlines(s) {
401
427
  return (s.match(/\n/gu) ?? []).length;
402
428
  }
429
+ function planCollapseEdit(gap) {
430
+ if (gap.comments.length > 0) {
431
+ return null;
432
+ }
433
+ const text = gap.src.slice(gap.start, gap.end);
434
+ if (countNewlines(text) < MIN_NEWLINES) {
435
+ return null;
436
+ }
437
+ const indent = text.slice(text.lastIndexOf(`
438
+ `) + 1);
439
+ return { start: gap.start, end: gap.end, replacement: `
440
+ ${indent}` };
441
+ }
403
442
 
404
443
  // src/transform/build-edits-from-ast.ts
405
444
  function buildEditsFromAST(src, parsed) {
@@ -432,8 +471,9 @@ function buildPairEdits(file, container, body) {
432
471
  for (let i = 0;i < body.length - 1; i++) {
433
472
  const prev = body[i];
434
473
  const next = body[i + 1];
435
- if (prev !== undefined && next !== undefined && !isImportPair(prev, next) && (needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next))) {
436
- const edit = planGapEdit(file, prev, next);
474
+ if (prev !== undefined && next !== undefined && !isImportPair(prev, next)) {
475
+ const pad = needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next);
476
+ const edit = planGapEdit({ file, prev, next, pad });
437
477
  if (edit !== null) {
438
478
  edits.push(edit);
439
479
  }
@@ -478,7 +518,7 @@ function transform(src, options) {
478
518
  }
479
519
 
480
520
  // src/cli/apply-diff-mode.ts
481
- import fs3 from "node:fs";
521
+ import fs4 from "node:fs";
482
522
 
483
523
  // src/cli/build-unified-diff.ts
484
524
  function buildUnifiedDiff(before, after, label) {
@@ -642,13 +682,13 @@ function applyDiffMode(edit, mode) {
642
682
  if (mode === "dry") {
643
683
  return { message: null, stdout: buildUnifiedDiff(edit.src, edit.result.output, edit.file) };
644
684
  }
645
- fs3.writeFileSync(edit.file, edit.result.output);
685
+ fs4.writeFileSync(edit.file, edit.result.output);
646
686
  return { message: `WROTE ${edit.file} ${edit.result.edits} edit(s)`, stdout: null };
647
687
  }
648
688
 
649
689
  // src/cli/check-file.ts
650
690
  function checkFile(file, mode) {
651
- const src = fs4.readFileSync(file, "utf8");
691
+ const src = fs5.readFileSync(file, "utf8");
652
692
  const bytes = Buffer.byteLength(src);
653
693
  const result = transform(src, { filename: file });
654
694
  if (result.parseError !== null) {
@@ -701,7 +741,8 @@ if (parsedArgs.version) {
701
741
  process.exit(0);
702
742
  }
703
743
  var t0 = Date.now();
704
- var files = await expandInputs(inputs, parsedArgs.ignore);
744
+ var ignore = [...loadFormatIgnore(process.cwd()), ...parsedArgs.ignore];
745
+ var files = await expandInputs(inputs, ignore);
705
746
  if (files.length === 0) {
706
747
  console.error("no files matched");
707
748
  process.exit(2);
package/dist/index.js CHANGED
@@ -40,6 +40,27 @@ function collectChildNodes(node) {
40
40
  return children;
41
41
  }
42
42
 
43
+ // src/transform/collect-head-chain.ts
44
+ var HEAD_PROPERTY = {
45
+ CallExpression: "callee",
46
+ MemberExpression: "object",
47
+ ChainExpression: "expression",
48
+ AwaitExpression: "argument",
49
+ TSNonNullExpression: "expression",
50
+ ParenthesizedExpression: "expression"
51
+ };
52
+ function collectHeadChain(expression) {
53
+ const chain = [];
54
+ let current = expression;
55
+ while (current !== undefined) {
56
+ chain.push(current);
57
+ const property = HEAD_PROPERTY[current.type];
58
+ const inner = property === undefined ? undefined : current[property];
59
+ current = isASTNode(inner) ? inner : undefined;
60
+ }
61
+ return chain;
62
+ }
63
+
43
64
  // src/transform/needs-blank-line.ts
44
65
  var CONTROL_FLOW_TYPES = new Set([
45
66
  "IfStatement",
@@ -54,19 +75,30 @@ function needsBlankLine(container, prev, next) {
54
75
  if (container.type === "ClassBody") {
55
76
  return true;
56
77
  }
57
- if (isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next)) {
78
+ if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
58
79
  return true;
59
80
  }
60
- if (next.type === "ReturnStatement") {
81
+ if (isTypeDecl(prev) || isTypeDecl(next)) {
61
82
  return true;
62
83
  }
63
- if (isFnOrClassDecl(prev)) {
84
+ return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
85
+ }
86
+ function isRunEnd(prev, next) {
87
+ return isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next) || isDirective(prev) && !isDirective(next) || prev.type === "ImportDeclaration" && next.type !== "ImportDeclaration";
88
+ }
89
+ function isDirective(node) {
90
+ return typeof node["directive"] === "string";
91
+ }
92
+ var TYPE_DECL_TYPES = new Set(["TSTypeAliasDeclaration", "TSInterfaceDeclaration"]);
93
+ function isTypeDecl(node) {
94
+ if (TYPE_DECL_TYPES.has(node.type)) {
64
95
  return true;
65
96
  }
66
- return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
97
+ const declaration = node.declaration;
98
+ return node.type === "ExportNamedDeclaration" && isASTNode(declaration) && TYPE_DECL_TYPES.has(declaration.type);
67
99
  }
68
100
  function isNewHeaded(node) {
69
- return getStatementValues(node).some((value) => getExpressionHead(value).type === "NewExpression");
101
+ return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
70
102
  }
71
103
  function getStatementValues(node) {
72
104
  if (node.type === "VariableDeclaration") {
@@ -82,44 +114,11 @@ function getStatementValues(node) {
82
114
  }
83
115
  return [expression];
84
116
  }
85
- var HEAD_PROPERTY = {
86
- CallExpression: "callee",
87
- MemberExpression: "object",
88
- ChainExpression: "expression",
89
- AwaitExpression: "argument",
90
- TSNonNullExpression: "expression",
91
- ParenthesizedExpression: "expression"
92
- };
93
- function getExpressionHead(node) {
94
- let current = node;
95
- let property = HEAD_PROPERTY[current.type];
96
- while (property !== undefined) {
97
- const inner = current[property];
98
- if (!isASTNode(inner)) {
99
- break;
100
- }
101
- current = inner;
102
- property = HEAD_PROPERTY[current.type];
103
- }
104
- return current;
105
- }
106
117
  function isAwaitHeaded(node) {
107
118
  if (node.type === "VariableDeclaration" && node.kind === "await using") {
108
119
  return true;
109
120
  }
110
- return getStatementValues(node).some((value) => hasAwaitInHeadChain(value));
111
- }
112
- function hasAwaitInHeadChain(node) {
113
- let current = node;
114
- while (current !== undefined) {
115
- if (current.type === "AwaitExpression") {
116
- return true;
117
- }
118
- const property = HEAD_PROPERTY[current.type];
119
- const inner = property === undefined ? undefined : current[property];
120
- current = isASTNode(inner) ? inner : undefined;
121
- }
122
- return false;
121
+ return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
123
122
  }
124
123
  var CALL_TYPES = ["CallExpression", "AwaitExpression"];
125
124
  var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
@@ -136,10 +135,20 @@ function getStatementKind(node) {
136
135
  return "declaration";
137
136
  }
138
137
  if (isExpressionStatementOf(node, CALL_TYPES)) {
139
- return "call";
138
+ return pickCallKind(node);
140
139
  }
141
140
  return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
142
141
  }
142
+ function pickCallKind(node) {
143
+ const expression = node["expression"];
144
+ const callee = isASTNode(expression) ? findDeepestCallee(expression) : null;
145
+ return callee?.type === "MemberExpression" ? "method-call" : "bare-call";
146
+ }
147
+ function findDeepestCallee(expression) {
148
+ const deepestCall = collectHeadChain(expression).findLast((link) => link.type === "CallExpression");
149
+ const callee = deepestCall?.["callee"];
150
+ return isASTNode(callee) ? callee : null;
151
+ }
143
152
  function isExpressionStatementOf(node, types) {
144
153
  const expression = node["expression"];
145
154
  return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
@@ -163,12 +172,12 @@ function isFnOrClassDecl(node) {
163
172
  }
164
173
 
165
174
  // src/transform/plan-gap-edit.ts
166
- function planGapEdit(file, prev, next) {
167
- const gap = buildGap(file, prev, next);
175
+ function planGapEdit(input) {
176
+ const gap = buildGap(input.file, input.prev, input.next);
168
177
  if (!isSafeToResize(gap)) {
169
178
  return null;
170
179
  }
171
- return planBlankLineEdit(gap);
180
+ return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
172
181
  }
173
182
  function buildGap(file, prev, next) {
174
183
  const start = getNodeEnd(prev);
@@ -250,6 +259,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
250
259
  function countNewlines(s) {
251
260
  return (s.match(/\n/gu) ?? []).length;
252
261
  }
262
+ function planCollapseEdit(gap) {
263
+ if (gap.comments.length > 0) {
264
+ return null;
265
+ }
266
+ const text = gap.src.slice(gap.start, gap.end);
267
+ if (countNewlines(text) < MIN_NEWLINES) {
268
+ return null;
269
+ }
270
+ const indent = text.slice(text.lastIndexOf(`
271
+ `) + 1);
272
+ return { start: gap.start, end: gap.end, replacement: `
273
+ ${indent}` };
274
+ }
253
275
 
254
276
  // src/transform/build-edits-from-ast.ts
255
277
  function buildEditsFromAST(src, parsed) {
@@ -282,8 +304,9 @@ function buildPairEdits(file, container, body) {
282
304
  for (let i = 0;i < body.length - 1; i++) {
283
305
  const prev = body[i];
284
306
  const next = body[i + 1];
285
- if (prev !== undefined && next !== undefined && !isImportPair(prev, next) && (needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next))) {
286
- const edit = planGapEdit(file, prev, next);
307
+ if (prev !== undefined && next !== undefined && !isImportPair(prev, next)) {
308
+ const pad = needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next);
309
+ const edit = planGapEdit({ file, prev, next, pad });
287
310
  if (edit !== null) {
288
311
  edits.push(edit);
289
312
  }
@@ -0,0 +1,9 @@
1
+ import type { ASTNode } from '../types.ts';
2
+ /**
3
+ * The nodes on the walk from an expression to its head, outermost first and
4
+ * ending at the head itself — `(await f()).prop` yields the member access,
5
+ * the parenthesis, the await, the call, and finally `f`. Nodes outside the
6
+ * walk (call arguments, ternary branches) never appear, so a scan of the
7
+ * chain sees only what a reader meets before the expression's first token.
8
+ */
9
+ export declare function collectHeadChain(expression: ASTNode): ASTNode[];
@@ -1,11 +1,13 @@
1
1
  import type { ASTNode } from '../types.ts';
2
2
  /**
3
3
  * The "always" rules: a blank line between class members, after a var or
4
- * using block before a statement of any other kind, before a return, after a
5
- * function/class declaration, on both sides of a control-flow block — its
6
- * closing brace ends a visual unit just like its opening keyword starts one —
7
- * and at the boundary between statement kinds: call vs mutation,
8
- * instantiation vs anything else, and awaited vs non-awaited. There are no
9
- * "never" rules, so any match means one blank.
4
+ * using block before a statement of any other kind, after a directive
5
+ * prologue, after the last import of a block, before a return, after a
6
+ * function/class declaration, on both sides of a type alias or interface
7
+ * declaration, on both sides of a control-flow block — its closing brace
8
+ * ends a visual unit just like its opening keyword starts one —
9
+ * and at the boundary between statement kinds: bare call vs method call vs
10
+ * mutation, instantiation vs anything else, and awaited vs non-awaited. Any
11
+ * match means exactly one blank line; a pair matching no rule sits flush.
10
12
  */
11
13
  export declare function needsBlankLine(container: ASTNode, prev: ASTNode, next: ASTNode): boolean;
@@ -1,8 +1,20 @@
1
1
  import type { ASTNode, Edit, SourceFile } from '../types.ts';
2
+ /**
3
+ * A pair of adjacent statements in a file and the gap shape they take: one
4
+ * blank line between them when `pad` is set, none otherwise.
5
+ */
6
+ export interface GapEditInput {
7
+ readonly file: SourceFile;
8
+ readonly prev: ASTNode;
9
+ readonly next: ASTNode;
10
+ readonly pad: boolean;
11
+ }
2
12
  /**
3
13
  * Plans the single whitespace splice that gives the gap between two statements
4
- * exactly one blank line, or null when the gap is already compliant or unsafe
5
- * to touch. Comment positions come from the parser rather than lexical
6
- * scanning, so comment-lookalike text can't mislead the classification.
14
+ * its target shape, or null when the gap is already compliant or unsafe to
15
+ * touch. Collapsing never crosses a comment: prose between statements marks
16
+ * grouping the padding rules can't see, so a comment-bearing gap only ever
17
+ * grows. Comment positions come from the parser rather than lexical scanning,
18
+ * so comment-lookalike text can't mislead the classification.
7
19
  */
8
- export declare function planGapEdit(file: SourceFile, prev: ASTNode, next: ASTNode): Edit | null;
20
+ export declare function planGapEdit(input: GapEditInput): Edit | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/format-codemod",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Enforce blank-line padding conventions in TypeScript via a fast text-splice codemod",
5
5
  "license": "MIT",
6
6
  "repository": {