@zgeoff/format-codemod 0.0.5 → 0.0.7

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,49 +1,67 @@
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
 
@@ -59,67 +77,43 @@ format-codemod [options] <file|dir|glob> ...
59
77
  --help
60
78
  ```
61
79
 
62
- Flags are strict: an unknown flag is a usage error (exit 2), never a silent no-op — the default mode
63
- writes files, so a typo'd `--check` must not fall through to a rewrite.
64
-
65
- ### 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).
66
84
 
67
85
  ```bash
68
86
  format-codemod 'src/**/*.{ts,tsx}' # rewrite in place
69
87
  format-codemod --check 'src/**/*.{ts,tsx}' # CI gate
70
- format-codemod --dry path/to/file.ts # preview diff
88
+ format-codemod --dry path/to/file.ts # preview diff; applies cleanly with patch(1)
71
89
  format-codemod . # whole repo, in place
72
90
  ```
73
91
 
74
- Globs use Node 22's built-in `fs.glob`. A literal path is resolved as a file before the glob
75
- heuristic runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are handled correctly
76
- rather than read as glob character classes. A directory argument expands to `**/*.{ts,tsx}` beneath
77
- it; `node_modules` and `.git` are never descended into, and overlapping patterns are deduped so a
78
- file is processed once.
92
+ ### Inputs
79
93
 
80
- `--dry` prints a real unified diff — `@@` hunk headers with three lines of context — that applies
81
- cleanly with `patch(1)`.
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.
82
101
 
83
102
  ### Ignoring files
84
103
 
85
- A `.formatignore` file in the working directory supplies ignore globs without bloating the command
86
- line — one per line, with blank lines and `#`-prefixed comment lines skipped:
104
+ A `.formatignore` file in the working directory supplies ignore globs — one per line, blank lines
105
+ and `#`-prefixed comment lines skipped:
87
106
 
88
107
  ```gitignore
89
108
  # committed codegen
90
109
  src/generated/**
91
-
92
- # build output
93
110
  dist/**
94
111
  ```
95
112
 
96
- Each line uses the same glob semantics as `--ignore` (Node's `path.matchesGlob`, tried against both
97
- the expanded path and the path relative to the working directory). It is not a gitignore dialect:
98
- `!` negation and directory-anchoring rules are not supported. `--ignore` flags remain available and
99
- merge additively with the file's globs.
100
-
101
- ## Dialect handling
102
-
103
- The filename picks the parse dialect: `.tsx` enables JSX, everything else parses as plain
104
- TypeScript. That means `.ts`-only syntax — old-style type assertions (`<string>value`), un-comma'd
105
- generic arrows (`<T>(a: T) => a`) — parses correctly instead of being misread as JSX.
106
-
107
- ## Trivia handling
108
-
109
- Comment positions come from the parser, not lexical scanning:
110
-
111
- - Trailing same-line comments stay attached to the previous statement: `const X = 1; // note` keeps
112
- the blank line _after_ the comment (several same-line comments all stay together).
113
- - Leading comments stay attached to the next statement: the blank line goes _before_ the comment.
114
- - Indentation of the next statement is preserved.
115
- - Two statements that share a physical line are left untouched — for example a leading-semicolon
116
- `;(expr)` whose `;` terminates the previous statement. A blank-only edit cannot separate them
117
- without orphaning a token, so the codemod skips the pair.
118
-
119
- ## Idempotent
120
-
121
- Running the codemod twice produces the same output as running it once. A run on already-compliant
122
- 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.
123
117
 
124
118
  ## Programmatic use
125
119
 
@@ -131,5 +125,5 @@ const { output, edits, parseError } = transform(source, { filename: 'component.t
131
125
  // parseError: error message if the source could not be parsed; source returned untouched
132
126
  ```
133
127
 
134
- `filename` is optional and only drives dialect selection (default `'source.ts'` — plain TypeScript,
135
- 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.
package/dist/cli.js CHANGED
@@ -207,6 +207,27 @@ function collectChildNodes(node) {
207
207
  return children;
208
208
  }
209
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
+
210
231
  // src/transform/needs-blank-line.ts
211
232
  var CONTROL_FLOW_TYPES = new Set([
212
233
  "IfStatement",
@@ -221,19 +242,30 @@ function needsBlankLine(container, prev, next) {
221
242
  if (container.type === "ClassBody") {
222
243
  return true;
223
244
  }
224
- if (isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next)) {
245
+ if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
225
246
  return true;
226
247
  }
227
- if (next.type === "ReturnStatement") {
248
+ if (isTypeDecl(prev) || isTypeDecl(next)) {
228
249
  return true;
229
250
  }
230
- 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)) {
231
262
  return true;
232
263
  }
233
- 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);
234
266
  }
235
267
  function isNewHeaded(node) {
236
- return getStatementValues(node).some((value) => getExpressionHead(value).type === "NewExpression");
268
+ return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
237
269
  }
238
270
  function getStatementValues(node) {
239
271
  if (node.type === "VariableDeclaration") {
@@ -249,44 +281,11 @@ function getStatementValues(node) {
249
281
  }
250
282
  return [expression];
251
283
  }
252
- var HEAD_PROPERTY = {
253
- CallExpression: "callee",
254
- MemberExpression: "object",
255
- ChainExpression: "expression",
256
- AwaitExpression: "argument",
257
- TSNonNullExpression: "expression",
258
- ParenthesizedExpression: "expression"
259
- };
260
- function getExpressionHead(node) {
261
- let current = node;
262
- let property = HEAD_PROPERTY[current.type];
263
- while (property !== undefined) {
264
- const inner = current[property];
265
- if (!isASTNode(inner)) {
266
- break;
267
- }
268
- current = inner;
269
- property = HEAD_PROPERTY[current.type];
270
- }
271
- return current;
272
- }
273
284
  function isAwaitHeaded(node) {
274
285
  if (node.type === "VariableDeclaration" && node.kind === "await using") {
275
286
  return true;
276
287
  }
277
- return getStatementValues(node).some((value) => hasAwaitInHeadChain(value));
278
- }
279
- function hasAwaitInHeadChain(node) {
280
- let current = node;
281
- while (current !== undefined) {
282
- if (current.type === "AwaitExpression") {
283
- return true;
284
- }
285
- const property = HEAD_PROPERTY[current.type];
286
- const inner = property === undefined ? undefined : current[property];
287
- current = isASTNode(inner) ? inner : undefined;
288
- }
289
- return false;
288
+ return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
290
289
  }
291
290
  var CALL_TYPES = ["CallExpression", "AwaitExpression"];
292
291
  var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
@@ -303,10 +302,20 @@ function getStatementKind(node) {
303
302
  return "declaration";
304
303
  }
305
304
  if (isExpressionStatementOf(node, CALL_TYPES)) {
306
- return "call";
305
+ return pickCallKind(node);
307
306
  }
308
307
  return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
309
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
+ }
310
319
  function isExpressionStatementOf(node, types) {
311
320
  const expression = node["expression"];
312
321
  return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
@@ -330,12 +339,12 @@ function isFnOrClassDecl(node) {
330
339
  }
331
340
 
332
341
  // src/transform/plan-gap-edit.ts
333
- function planGapEdit(file, prev, next) {
334
- const gap = buildGap(file, prev, next);
342
+ function planGapEdit(input) {
343
+ const gap = buildGap(input.file, input.prev, input.next);
335
344
  if (!isSafeToResize(gap)) {
336
345
  return null;
337
346
  }
338
- return planBlankLineEdit(gap);
347
+ return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
339
348
  }
340
349
  function buildGap(file, prev, next) {
341
350
  const start = getNodeEnd(prev);
@@ -417,6 +426,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
417
426
  function countNewlines(s) {
418
427
  return (s.match(/\n/gu) ?? []).length;
419
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
+ }
420
442
 
421
443
  // src/transform/build-edits-from-ast.ts
422
444
  function buildEditsFromAST(src, parsed) {
@@ -449,8 +471,9 @@ function buildPairEdits(file, container, body) {
449
471
  for (let i = 0;i < body.length - 1; i++) {
450
472
  const prev = body[i];
451
473
  const next = body[i + 1];
452
- if (prev !== undefined && next !== undefined && !isImportPair(prev, next) && (needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next))) {
453
- 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 });
454
477
  if (edit !== null) {
455
478
  edits.push(edit);
456
479
  }
@@ -527,10 +550,10 @@ class MyersDiff {
527
550
  this.b = b;
528
551
  }
529
552
  buildOps() {
530
- this.computeTrace();
553
+ this.updateTrace();
531
554
  return this.backtrack();
532
555
  }
533
- computeTrace() {
556
+ updateTrace() {
534
557
  for (let d = 0;d <= this.a.length + this.b.length; d++) {
535
558
  this.trace.push(new Map(this.v));
536
559
  if (this.stepRound(d)) {
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.5",
3
+ "version": "0.0.7",
4
4
  "description": "Enforce blank-line padding conventions in TypeScript via a fast text-splice codemod",
5
5
  "license": "MIT",
6
6
  "repository": {