@zgeoff/format-codemod 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Geoff Whatley
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @zgeoff/format-codemod
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.
8
+
9
+ ## Rules implemented
10
+
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.
14
+
15
+ **`padding-line-between-statements`** — insert one blank line:
16
+
17
+ - after a `const`/`let`/`var` block, before any non-var statement
18
+ - before any `return`
19
+ - 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 call statement, an
23
+ assignment/increment statement — in any order; runs of one kind stay tight
24
+ - at the boundary between an instantiation-headed statement (`new` at the head of its expression
25
+ chain, as in `new X(…)` or `new X(…).method()`) and any other statement kind — `new` in argument
26
+ position doesn't count, and runs of instantiations stay glued
27
+
28
+ **`@stylistic/lines-between-class-members`** — one blank line between class members.
29
+
30
+ ### Notes
31
+
32
+ - **Consecutive single-line `const`/`let`/`var` declarations stay glued** (no blank between them); a
33
+ multiline declaration is separated like any other multiline statement.
34
+ - **Bare declarations only.** `export const`, `export function`, and `export class` are not matched
35
+ — ESLint's selectors do not look through `export`, and neither does this. `await using` is
36
+ likewise not a var declaration.
37
+ - **`.d.ts` files are skipped.**
38
+
39
+ ## CLI
40
+
41
+ ```bash
42
+ format-codemod [options] <file|dir|glob> ...
43
+
44
+ --check exit 1 if any file would change; do not write
45
+ --dry print unified diff to stdout; do not write
46
+ --bench print parse stats as JSON to stderr
47
+ --quiet only print files that would change
48
+ --version
49
+ --help
50
+ ```
51
+
52
+ Flags are strict: an unknown flag is a usage error (exit 2), never a silent no-op — the default mode
53
+ writes files, so a typo'd `--check` must not fall through to a rewrite.
54
+
55
+ ### Examples
56
+
57
+ ```bash
58
+ format-codemod 'src/**/*.{ts,tsx}' # rewrite in place
59
+ format-codemod --check 'src/**/*.{ts,tsx}' # CI gate
60
+ format-codemod --dry path/to/file.ts # preview diff
61
+ format-codemod . # whole repo, in place
62
+ ```
63
+
64
+ Globs use Node 22's built-in `fs.glob`. A literal path is resolved as a file before the glob
65
+ heuristic runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are handled correctly
66
+ rather than read as glob character classes. A directory argument expands to `**/*.{ts,tsx}` beneath
67
+ it; `node_modules` and `.git` are never descended into, and overlapping patterns are deduped so a
68
+ file is processed once.
69
+
70
+ `--dry` prints a real unified diff — `@@` hunk headers with three lines of context — that applies
71
+ cleanly with `patch(1)`.
72
+
73
+ ## Dialect handling
74
+
75
+ The filename picks the parse dialect: `.tsx` enables JSX, everything else parses as plain
76
+ TypeScript. That means `.ts`-only syntax — old-style type assertions (`<string>value`), un-comma'd
77
+ generic arrows (`<T>(a: T) => a`) — parses correctly instead of being misread as JSX.
78
+
79
+ ## Trivia handling
80
+
81
+ Comment positions come from the parser, not lexical scanning:
82
+
83
+ - Trailing same-line comments stay attached to the previous statement: `const X = 1; // note` keeps
84
+ the blank line _after_ the comment (several same-line comments all stay together).
85
+ - Leading comments stay attached to the next statement: the blank line goes _before_ the comment.
86
+ - Indentation of the next statement is preserved.
87
+ - Two statements that share a physical line are left untouched — for example a leading-semicolon
88
+ `;(expr)` whose `;` terminates the previous statement. A blank-only edit cannot separate them
89
+ without orphaning a token, so the codemod skips the pair.
90
+
91
+ ## Idempotent
92
+
93
+ Running the codemod twice produces the same output as running it once. A run on already-compliant
94
+ code is a no-op.
95
+
96
+ ## Programmatic use
97
+
98
+ ```ts
99
+ import { transform } from '@zgeoff/format-codemod';
100
+
101
+ const { output, edits, parseError } = transform(source, { filename: 'component.tsx' });
102
+ // edits: number of splices applied
103
+ // parseError: error message if the source could not be parsed; source returned untouched
104
+ ```
105
+
106
+ `filename` is optional and only drives dialect selection (default `'source.ts'` — plain TypeScript,
107
+ no JSX); nothing is read from disk.
@@ -0,0 +1,11 @@
1
+ import type { CLIMode, FileEdit } from './types.ts';
2
+ export interface DiffOutput {
3
+ readonly message: string | null;
4
+ readonly stdout: string | null;
5
+ }
6
+ /**
7
+ * Reached only when the file needs edits. Write mode saves the file here — the
8
+ * one file-system effect below the entry; what to print is returned, never
9
+ * printed, because the entry owns the output streams.
10
+ */
11
+ export declare function applyDiffMode(edit: FileEdit, mode: CLIMode): DiffOutput;
@@ -0,0 +1,2 @@
1
+ import type { BenchStats, FileReport } from './types.ts';
2
+ export declare function buildBenchStatsFromReports(reports: readonly FileReport[], ms: number): BenchStats;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A real unified diff — @@ hunk headers with context — so --dry output can be
3
+ * applied with patch(1). Inputs are expected to end with a newline, which
4
+ * TypeScript sources do and the transform preserves.
5
+ */
6
+ export declare function buildUnifiedDiff(before: string, after: string, label: string): string;
@@ -0,0 +1,2 @@
1
+ import type { CLIMode, FileReport } from './types.ts';
2
+ export declare function checkFile(file: string, mode: CLIMode): FileReport;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Deduped: overlapping patterns (a `src/**` glob plus a file inside src/) must
3
+ * not process — or report — the same file twice.
4
+ */
5
+ export declare function expandInputs(patterns: readonly string[]): Promise<string[]>;
@@ -0,0 +1,16 @@
1
+ import type { CLIMode } from './types.ts';
2
+ export interface CLIArgs {
3
+ readonly mode: CLIMode;
4
+ readonly quiet: boolean;
5
+ readonly bench: boolean;
6
+ readonly help: boolean;
7
+ readonly version: boolean;
8
+ readonly inputs: readonly string[];
9
+ }
10
+ /**
11
+ * Strict parsing: an unknown flag is a usage error, not a silent no-op. That
12
+ * matters because the default mode writes files — a typo'd --check must never
13
+ * fall through to an in-place rewrite. CLIArgs is always an object, so the
14
+ * string (error message) return is unambiguous.
15
+ */
16
+ export declare function parseCLIArgs(argv: readonly string[]): CLIArgs | string;
@@ -0,0 +1 @@
1
+ export declare function printHelp(): void;
@@ -0,0 +1,7 @@
1
+ import type { FileReport } from './types.ts';
2
+ /**
3
+ * Prints one file's outcome: the stdout payload (--dry diffs) to stdout,
4
+ * messages and per-file progress to stderr. Quiet drops the OK/SKIP noise but
5
+ * never the messages.
6
+ */
7
+ export declare function printReport(file: string, report: FileReport, quiet: boolean): void;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Reads the version field of the package manifest at the given path, throwing
3
+ * when the file isn't a manifest. Callers own the path so it resolves against
4
+ * their own location.
5
+ */
6
+ export declare function readPackageVersion(pkgPath: string): string;
@@ -0,0 +1,7 @@
1
+ import type { CLIMode, FileReport } from './types.ts';
2
+ /**
3
+ * A failed file must fail the batch: --check would otherwise exit 0 having
4
+ * validated nothing, and a throw would kill the process mid-batch with the
5
+ * remaining files silently skipped.
6
+ */
7
+ export declare function tryCheckFile(file: string, mode: CLIMode): FileReport;
@@ -0,0 +1,28 @@
1
+ import type { TransformResult } from '../types.ts';
2
+ export type CLIMode = 'write' | 'check' | 'dry';
3
+ export type FileOutcome = 'ok' | 'changed' | 'failed' | 'skipped';
4
+ /**
5
+ * Everything the entry needs to report one file: the outcome for exit-code
6
+ * logic, sizes for --bench, and the text to print — modules below the entry
7
+ * never write to stdout/stderr themselves.
8
+ */
9
+ export interface FileReport {
10
+ readonly outcome: FileOutcome;
11
+ readonly bytes: number;
12
+ readonly parsed: boolean;
13
+ readonly message: string | null;
14
+ readonly stdout: string | null;
15
+ }
16
+ export interface FileEdit {
17
+ readonly file: string;
18
+ readonly src: string;
19
+ readonly result: TransformResult;
20
+ }
21
+ export interface BenchStats {
22
+ readonly files: number;
23
+ readonly parsed: number;
24
+ readonly bytes: number;
25
+ readonly ms: number;
26
+ readonly us_per_file: number;
27
+ readonly mb_per_sec: number;
28
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,672 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // src/cli/build-bench-stats-from-reports.ts
7
+ function buildBenchStatsFromReports(reports, ms) {
8
+ const bytes = reports.reduce((sum, r) => sum + r.bytes, 0);
9
+ const parsed = reports.filter((r) => r.parsed).length;
10
+ return {
11
+ files: reports.length,
12
+ parsed,
13
+ bytes,
14
+ ms,
15
+ us_per_file: Math.round(ms * 1000 / reports.length),
16
+ mb_per_sec: Math.round(bytes / 1024 / 1024 / (ms / 1000) * 10) / 10
17
+ };
18
+ }
19
+
20
+ // src/cli/expand-inputs.ts
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ async function expandInputs(patterns) {
24
+ const lists = await Promise.all(patterns.map((p) => expandPattern(p)));
25
+ return [...new Set(lists.flat())];
26
+ }
27
+ function expandPattern(p) {
28
+ if (fs.existsSync(p)) {
29
+ if (fs.statSync(p).isDirectory()) {
30
+ const pattern = path.join(p, "**/*.{ts,tsx}");
31
+ return Array.fromAsync(fs.promises.glob(pattern, { exclude: isExcluded }));
32
+ }
33
+ return Promise.resolve([p]);
34
+ }
35
+ if (/[*?[{]/u.test(p)) {
36
+ return Array.fromAsync(fs.promises.glob(p, { exclude: isExcluded }));
37
+ }
38
+ return Promise.resolve([p]);
39
+ }
40
+ function isExcluded(entry) {
41
+ const p = typeof entry === "string" ? entry : path.join(entry.parentPath ?? "", entry.name);
42
+ return p.split(/[\\/]/u).some((seg) => seg === "node_modules" || seg === ".git");
43
+ }
44
+
45
+ // src/cli/parse-cli-args.ts
46
+ import { parseArgs } from "node:util";
47
+ function parseCLIArgs(argv) {
48
+ try {
49
+ const { values, positionals } = parseArgs({
50
+ args: [...argv],
51
+ options: {
52
+ check: { type: "boolean", default: false },
53
+ dry: { type: "boolean", default: false },
54
+ bench: { type: "boolean", default: false },
55
+ quiet: { type: "boolean", default: false },
56
+ version: { type: "boolean", default: false },
57
+ help: { type: "boolean", default: false }
58
+ },
59
+ allowPositionals: true,
60
+ strict: true
61
+ });
62
+ return {
63
+ mode: pickMode(values.check, values.dry),
64
+ quiet: values.quiet,
65
+ bench: values.bench,
66
+ help: values.help,
67
+ version: values.version,
68
+ inputs: positionals
69
+ };
70
+ } catch (error) {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+ }
74
+ function pickMode(check, dry) {
75
+ if (check) {
76
+ return "check";
77
+ }
78
+ return dry ? "dry" : "write";
79
+ }
80
+
81
+ // src/cli/print-help.ts
82
+ function printHelp() {
83
+ process.stdout.write(`format-codemod — enforce blank-line padding conventions via text splice
84
+
85
+ usage:
86
+ format-codemod [options] <file|dir|glob> ...
87
+
88
+ options:
89
+ --check exit 1 if any file would change (no writes)
90
+ --dry print unified diff to stdout (no writes)
91
+ --bench print parse stats as JSON to stderr
92
+ --quiet only print files that would change
93
+ --version
94
+ --help
95
+
96
+ exit codes:
97
+ 0 clean
98
+ 1 files would change (--check)
99
+ 2 usage error, or one or more files failed (parse error, unreadable, unwritable)
100
+
101
+ examples:
102
+ format-codemod 'src/**/*.ts'
103
+ format-codemod --check 'src/**/*.{ts,tsx}'
104
+ format-codemod --dry path/to/file.ts
105
+ `);
106
+ }
107
+
108
+ // src/cli/print-report.ts
109
+ function printReport(file, report, quiet) {
110
+ if (report.stdout !== null) {
111
+ process.stdout.write(report.stdout);
112
+ }
113
+ if (report.message !== null) {
114
+ console.error(report.message);
115
+ }
116
+ if (!quiet && report.outcome === "ok") {
117
+ console.error(`OK ${file}`);
118
+ }
119
+ if (!quiet && report.outcome === "skipped") {
120
+ console.error(`SKIP ${file} declaration file`);
121
+ }
122
+ }
123
+
124
+ // src/cli/read-package-version.ts
125
+ import fs2 from "node:fs";
126
+ function readPackageVersion(pkgPath) {
127
+ const parsed = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
128
+ if (!isPackageJSON(parsed)) {
129
+ throw new TypeError("Invalid package.json");
130
+ }
131
+ return parsed.version;
132
+ }
133
+ function isPackageJSON(value) {
134
+ return typeof value === "object" && value !== null && "version" in value && typeof value.version === "string";
135
+ }
136
+
137
+ // src/cli/check-file.ts
138
+ import fs4 from "node:fs";
139
+
140
+ // src/transform/apply-edits.ts
141
+ function applyEdits(src, edits) {
142
+ const segments = [];
143
+ let tail = src.length;
144
+ for (const e of edits) {
145
+ segments.push(src.slice(e.end, tail), e.replacement);
146
+ tail = e.start;
147
+ }
148
+ segments.push(src.slice(0, tail));
149
+ return segments.toReversed().join("");
150
+ }
151
+
152
+ // src/transform/plan-gap-edit.ts
153
+ function planGapEdit(file, prev, next) {
154
+ const gap = buildGap(file, prev, next);
155
+ if (!isSafeToResize(gap)) {
156
+ return null;
157
+ }
158
+ return planBlankLineEdit(gap);
159
+ }
160
+ function buildGap(file, prev, next) {
161
+ const start = getNodeEnd(prev);
162
+ const end = getNodeStart(next);
163
+ const comments = file.comments.filter((c) => c.start >= start && c.end <= end);
164
+ return { src: file.src, start, end, comments };
165
+ }
166
+ function getNodeEnd(n) {
167
+ if (typeof n.end !== "number") {
168
+ throw new TypeError("AST node is missing an end position");
169
+ }
170
+ return n.end;
171
+ }
172
+ function getNodeStart(n) {
173
+ if (typeof n.start !== "number") {
174
+ throw new TypeError("AST node is missing a start position");
175
+ }
176
+ return n.start;
177
+ }
178
+ function isSafeToResize(gap) {
179
+ return isTriviaOnly(gap) && gap.src.slice(gap.start, gap.end).includes(`
180
+ `);
181
+ }
182
+ function isTriviaOnly(gap) {
183
+ let cursor = gap.start;
184
+ for (const c of gap.comments) {
185
+ if (gap.src.slice(cursor, c.start).trim() !== "") {
186
+ return false;
187
+ }
188
+ cursor = c.end;
189
+ }
190
+ return gap.src.slice(cursor, gap.end).trim() === "";
191
+ }
192
+ function planBlankLineEdit(gap) {
193
+ const effectiveStart = getEffectiveGapStart(gap);
194
+ const leadingComment = gap.comments.find((c) => c.start >= effectiveStart);
195
+ if (leadingComment !== undefined) {
196
+ return planLeadingCommentEdit(gap.src, effectiveStart, leadingComment.start);
197
+ }
198
+ return planWhitespaceGap(gap.src.slice(effectiveStart, gap.end), effectiveStart, gap.end);
199
+ }
200
+ function getEffectiveGapStart(gap) {
201
+ let pos = gap.start;
202
+ for (const c of gap.comments) {
203
+ if (gap.src.slice(pos, c.start).includes(`
204
+ `)) {
205
+ break;
206
+ }
207
+ pos = c.end;
208
+ }
209
+ return pos;
210
+ }
211
+ var MIN_NEWLINES = 2;
212
+ function planLeadingCommentEdit(src, restStart, commentStart) {
213
+ const leading = src.slice(restStart, commentStart);
214
+ const leadingNewlines = countNewlines(leading);
215
+ if (leadingNewlines >= MIN_NEWLINES) {
216
+ return null;
217
+ }
218
+ return {
219
+ start: restStart,
220
+ end: commentStart,
221
+ replacement: `${`
222
+ `.repeat(MIN_NEWLINES - leadingNewlines)}${leading}`
223
+ };
224
+ }
225
+ function planWhitespaceGap(gap, gapStart, gapEnd) {
226
+ if (countNewlines(gap) >= MIN_NEWLINES) {
227
+ return null;
228
+ }
229
+ const lastNL = gap.lastIndexOf(`
230
+ `);
231
+ const indent = lastNL === -1 ? "" : gap.slice(lastNL + 1);
232
+ const trimmed = gap.slice(0, gap.length - indent.length).replace(/\n*$/u, "");
233
+ const newGap = `${trimmed}${`
234
+ `.repeat(MIN_NEWLINES)}${indent}`;
235
+ return { start: gapStart, end: gapEnd, replacement: newGap };
236
+ }
237
+ function countNewlines(s) {
238
+ return (s.match(/\n/gu) ?? []).length;
239
+ }
240
+
241
+ // src/transform/build-edits-from-ast.ts
242
+ function buildEditsFromAST(src, parsed) {
243
+ const file = { src, comments: parsed.comments };
244
+ return walk(file, parsed.program).toSorted((a, b) => b.start - a.start);
245
+ }
246
+ function walk(file, node) {
247
+ const edits = [];
248
+ for (const body of getStatementLists(node)) {
249
+ edits.push(...buildPairEdits(file, node, body));
250
+ }
251
+ for (const child of collectChildNodes(node)) {
252
+ edits.push(...walk(file, child));
253
+ }
254
+ return edits;
255
+ }
256
+ function getStatementLists(node) {
257
+ const bodies = [];
258
+ const hasBlockBody = node.type === "Program" || node.type === "BlockStatement" || node.type === "ClassBody";
259
+ if (hasBlockBody && Array.isArray(node.body)) {
260
+ bodies.push(node.body);
261
+ }
262
+ if (node.type === "SwitchCase" && Array.isArray(node.consequent)) {
263
+ bodies.push(node.consequent);
264
+ }
265
+ return bodies;
266
+ }
267
+ function buildPairEdits(file, container, body) {
268
+ const edits = [];
269
+ for (let i = 0;i < body.length - 1; i++) {
270
+ const prev = body[i];
271
+ const next = body[i + 1];
272
+ if (prev !== undefined && next !== undefined && (needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next))) {
273
+ const edit = planGapEdit(file, prev, next);
274
+ if (edit !== null) {
275
+ edits.push(edit);
276
+ }
277
+ }
278
+ }
279
+ return edits;
280
+ }
281
+ function isMultiline(src, node) {
282
+ return typeof node.start === "number" && typeof node.end === "number" && src.slice(node.start, node.end).includes(`
283
+ `);
284
+ }
285
+ var CONTROL_FLOW_TYPES = new Set([
286
+ "IfStatement",
287
+ "ForStatement",
288
+ "ForInStatement",
289
+ "ForOfStatement",
290
+ "WhileStatement",
291
+ "SwitchStatement",
292
+ "TryStatement"
293
+ ]);
294
+ function needsBlankLine(container, prev, next) {
295
+ if (container.type === "ClassBody") {
296
+ return true;
297
+ }
298
+ if (isVarDecl(prev) && !isVarDecl(next)) {
299
+ return true;
300
+ }
301
+ if (next.type === "ReturnStatement") {
302
+ return true;
303
+ }
304
+ if (isFnOrClassDecl(prev)) {
305
+ return true;
306
+ }
307
+ return isNewHeaded(prev) !== isNewHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
308
+ }
309
+ function isNewHeaded(node) {
310
+ return getStatementValues(node).some((value) => getExpressionHead(value).type === "NewExpression");
311
+ }
312
+ function getStatementValues(node) {
313
+ if (node.type === "VariableDeclaration") {
314
+ return collectASTNodes(node["declarations"]).map((declarator) => declarator["init"]).filter((init) => isASTNode(init));
315
+ }
316
+ const { expression } = node;
317
+ if (node.type !== "ExpressionStatement" || !isASTNode(expression)) {
318
+ return [];
319
+ }
320
+ const { right } = expression;
321
+ if (expression.type === "AssignmentExpression" && isASTNode(right)) {
322
+ return [right];
323
+ }
324
+ return [expression];
325
+ }
326
+ var HEAD_PROPERTY = {
327
+ CallExpression: "callee",
328
+ MemberExpression: "object",
329
+ ChainExpression: "expression",
330
+ AwaitExpression: "argument",
331
+ TSNonNullExpression: "expression",
332
+ ParenthesizedExpression: "expression"
333
+ };
334
+ function getExpressionHead(node) {
335
+ let current = node;
336
+ let property = HEAD_PROPERTY[current.type];
337
+ while (property !== undefined) {
338
+ const inner = current[property];
339
+ if (!isASTNode(inner)) {
340
+ break;
341
+ }
342
+ current = inner;
343
+ property = HEAD_PROPERTY[current.type];
344
+ }
345
+ return current;
346
+ }
347
+ var CALL_TYPES = ["CallExpression", "AwaitExpression"];
348
+ var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
349
+ function isKindBoundary(prev, next) {
350
+ const prevKind = getStatementKind(prev);
351
+ const nextKind = getStatementKind(next);
352
+ return prevKind !== null && nextKind !== null && prevKind !== nextKind;
353
+ }
354
+ function getStatementKind(node) {
355
+ if (isVarDecl(node)) {
356
+ return "declaration";
357
+ }
358
+ if (isExpressionStatementOf(node, CALL_TYPES)) {
359
+ return "call";
360
+ }
361
+ return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
362
+ }
363
+ function isExpressionStatementOf(node, types) {
364
+ const { expression } = node;
365
+ return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
366
+ }
367
+ var VAR_DECL_KINDS = new Set(["const", "let", "var"]);
368
+ function isVarDecl(node) {
369
+ if (node.type === "VariableDeclaration") {
370
+ return node.kind !== undefined && VAR_DECL_KINDS.has(node.kind);
371
+ }
372
+ return false;
373
+ }
374
+ function isFnOrClassDecl(node) {
375
+ return node.type === "FunctionDeclaration" || node.type === "ClassDeclaration";
376
+ }
377
+ function collectChildNodes(node) {
378
+ const children = [];
379
+ for (const key of Object.keys(node)) {
380
+ if (key !== "loc" && key !== "parent") {
381
+ children.push(...collectASTNodes(node[key]));
382
+ }
383
+ }
384
+ return children;
385
+ }
386
+ function collectASTNodes(value) {
387
+ if (!Array.isArray(value)) {
388
+ return isASTNode(value) ? [value] : [];
389
+ }
390
+ const nodes = [];
391
+ for (const item of value) {
392
+ if (isASTNode(item)) {
393
+ nodes.push(item);
394
+ }
395
+ }
396
+ return nodes;
397
+ }
398
+ function isASTNode(value) {
399
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
400
+ }
401
+
402
+ // src/transform/parse-source.ts
403
+ import { parseSync } from "oxc-parser";
404
+ function parseSource(src, filename) {
405
+ const parsed = parseSync(filename, src);
406
+ const [firstError] = parsed.errors;
407
+ if (firstError !== undefined) {
408
+ return firstError.message;
409
+ }
410
+ if (!isASTNode2(parsed.program)) {
411
+ throw new TypeError("oxc-parser returned a malformed program node");
412
+ }
413
+ return { program: parsed.program, comments: parsed.comments };
414
+ }
415
+ function isASTNode2(value) {
416
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
417
+ }
418
+
419
+ // src/transform.ts
420
+ function transform(src, options) {
421
+ const parsed = parseSource(src, options?.filename ?? "source.ts");
422
+ if (typeof parsed === "string") {
423
+ return { output: src, edits: 0, parseError: parsed };
424
+ }
425
+ const editList = buildEditsFromAST(src, parsed);
426
+ const output = applyEdits(src, editList);
427
+ return { output, edits: editList.length, parseError: null };
428
+ }
429
+
430
+ // src/cli/apply-diff-mode.ts
431
+ import fs3 from "node:fs";
432
+
433
+ // src/cli/build-unified-diff.ts
434
+ function buildUnifiedDiff(before, after, label) {
435
+ const ops = new MyersDiff(splitLines(before), splitLines(after)).buildOps();
436
+ const hunks = buildHunks(ops);
437
+ if (hunks.length === 0) {
438
+ return "";
439
+ }
440
+ return [`--- ${label}
441
+ `, `+++ ${label}
442
+ `, ...hunks.map((h) => renderHunk(h))].join("");
443
+ }
444
+ function splitLines(s) {
445
+ const lines = s.split(`
446
+ `);
447
+ if (lines.at(-1) === "") {
448
+ lines.pop();
449
+ }
450
+ return lines;
451
+ }
452
+
453
+ class MyersDiff {
454
+ a;
455
+ b;
456
+ v = new Map([[1, 0]]);
457
+ trace = [];
458
+ constructor(a, b) {
459
+ this.a = a;
460
+ this.b = b;
461
+ }
462
+ buildOps() {
463
+ this.computeTrace();
464
+ return this.backtrack();
465
+ }
466
+ computeTrace() {
467
+ for (let d = 0;d <= this.a.length + this.b.length; d++) {
468
+ this.trace.push(new Map(this.v));
469
+ if (this.stepRound(d)) {
470
+ return;
471
+ }
472
+ }
473
+ }
474
+ stepRound(d) {
475
+ for (let k = -d;k <= d; k += 2) {
476
+ const x = this.slideDiagonal(this.pickX(k, d), k);
477
+ this.v.set(k, x);
478
+ if (x >= this.a.length && x - k >= this.b.length) {
479
+ return true;
480
+ }
481
+ }
482
+ return false;
483
+ }
484
+ pickX(k, d) {
485
+ const moveDown = k === -d || k !== d && (this.v.get(k - 1) ?? 0) < (this.v.get(k + 1) ?? 0);
486
+ return moveDown ? this.v.get(k + 1) ?? 0 : (this.v.get(k - 1) ?? 0) + 1;
487
+ }
488
+ slideDiagonal(x, k) {
489
+ let nx = x;
490
+ while (nx < this.a.length && nx - k < this.b.length && this.a[nx] === this.b[nx - k]) {
491
+ nx++;
492
+ }
493
+ return nx;
494
+ }
495
+ backtrack() {
496
+ const ops = [];
497
+ let pos = { x: this.a.length, y: this.b.length };
498
+ for (let d = this.trace.length - 1;d >= 0; d--) {
499
+ const round = this.unwindRound(pos, d);
500
+ ops.push(...round.ops);
501
+ pos = round.pos;
502
+ }
503
+ return ops.toReversed();
504
+ }
505
+ unwindRound(pos, d) {
506
+ const { prev, moveDown } = this.findPrevious(pos, d);
507
+ const ops = this.buildEqualOps(pos, prev);
508
+ if (d > 0) {
509
+ const changeOp = moveDown ? { kind: "+", text: this.b[prev.y] ?? "" } : { kind: "-", text: this.a[prev.x] ?? "" };
510
+ ops.push(changeOp);
511
+ }
512
+ return { ops, pos: prev };
513
+ }
514
+ findPrevious(pos, d) {
515
+ const v = this.trace[d] ?? new Map;
516
+ const k = pos.x - pos.y;
517
+ const moveDown = k === -d || k !== d && (v.get(k - 1) ?? 0) < (v.get(k + 1) ?? 0);
518
+ const prevK = moveDown ? k + 1 : k - 1;
519
+ const prevX = v.get(prevK) ?? 0;
520
+ return { prev: { x: prevX, y: prevX - prevK }, moveDown };
521
+ }
522
+ buildEqualOps(pos, prev) {
523
+ const ops = [];
524
+ let { x, y } = pos;
525
+ while (x > prev.x && y > prev.y) {
526
+ ops.push({ kind: " ", text: this.a[x - 1] ?? "" });
527
+ x--;
528
+ y--;
529
+ }
530
+ return ops;
531
+ }
532
+ }
533
+ function buildHunks(ops) {
534
+ return buildWindows(ops).map((w) => buildHunk(ops, w));
535
+ }
536
+ var CONTEXT_LINES = 3;
537
+ function buildWindows(ops) {
538
+ const windows = [];
539
+ for (let i = 0;i < ops.length; i++) {
540
+ if (ops[i]?.kind !== " ") {
541
+ const from = Math.max(0, i - CONTEXT_LINES);
542
+ const to = Math.min(ops.length, i + CONTEXT_LINES + 1);
543
+ const last = windows.at(-1);
544
+ if (last !== undefined && from <= last.to) {
545
+ windows[windows.length - 1] = { from: last.from, to };
546
+ } else {
547
+ windows.push({ from, to });
548
+ }
549
+ }
550
+ }
551
+ return windows;
552
+ }
553
+ function buildHunk(ops, w) {
554
+ const { aLine, bLine } = countPrecedingLines(ops, w.from);
555
+ const slice = ops.slice(w.from, w.to);
556
+ const aCount = slice.filter((o) => o.kind !== "+").length;
557
+ const bCount = slice.filter((o) => o.kind !== "-").length;
558
+ return {
559
+ header: `@@ -${formatRange(aLine, aCount)} +${formatRange(bLine, bCount)} @@
560
+ `,
561
+ lines: slice.map((o) => `${o.kind}${o.text}
562
+ `)
563
+ };
564
+ }
565
+ function countPrecedingLines(ops, from) {
566
+ let aLine = 0;
567
+ let bLine = 0;
568
+ for (const op of ops.slice(0, from)) {
569
+ if (op.kind !== "+") {
570
+ aLine++;
571
+ }
572
+ if (op.kind !== "-") {
573
+ bLine++;
574
+ }
575
+ }
576
+ return { aLine, bLine };
577
+ }
578
+ function formatRange(line, count) {
579
+ return count === 0 ? `${line},0` : `${line + 1},${count}`;
580
+ }
581
+ function renderHunk(h) {
582
+ return `${h.header}${h.lines.join("")}`;
583
+ }
584
+
585
+ // src/cli/apply-diff-mode.ts
586
+ function applyDiffMode(edit, mode) {
587
+ if (mode === "check") {
588
+ return { message: `DIFF ${edit.file} ${edit.result.edits} edit(s)`, stdout: null };
589
+ }
590
+ if (mode === "dry") {
591
+ return { message: null, stdout: buildUnifiedDiff(edit.src, edit.result.output, edit.file) };
592
+ }
593
+ fs3.writeFileSync(edit.file, edit.result.output);
594
+ return { message: `WROTE ${edit.file} ${edit.result.edits} edit(s)`, stdout: null };
595
+ }
596
+
597
+ // src/cli/check-file.ts
598
+ function checkFile(file, mode) {
599
+ const src = fs4.readFileSync(file, "utf8");
600
+ const bytes = Buffer.byteLength(src);
601
+ const result = transform(src, { filename: file });
602
+ if (result.parseError !== null) {
603
+ return {
604
+ outcome: "failed",
605
+ bytes,
606
+ parsed: false,
607
+ message: `PARSE-ERR ${file} ${result.parseError}`,
608
+ stdout: null
609
+ };
610
+ }
611
+ if (result.edits === 0 || result.output === src) {
612
+ return { outcome: "ok", bytes, parsed: true, message: null, stdout: null };
613
+ }
614
+ return { outcome: "changed", bytes, parsed: true, ...applyDiffMode({ file, src, result }, mode) };
615
+ }
616
+
617
+ // src/cli/try-check-file.ts
618
+ function tryCheckFile(file, mode) {
619
+ if (file.endsWith(".d.ts")) {
620
+ return { outcome: "skipped", bytes: 0, parsed: false, message: null, stdout: null };
621
+ }
622
+ try {
623
+ return checkFile(file, mode);
624
+ } catch (error) {
625
+ const message = `ERROR ${file} ${error instanceof Error ? error.message : String(error)}`;
626
+ return { outcome: "failed", bytes: 0, parsed: false, message, stdout: null };
627
+ }
628
+ }
629
+
630
+ // src/cli.ts
631
+ var parsedArgs = parseCLIArgs(process.argv.slice(2));
632
+ if (typeof parsedArgs === "string") {
633
+ console.error(parsedArgs);
634
+ process.exit(2);
635
+ }
636
+ var { mode, quiet, bench, help, version, inputs } = parsedArgs;
637
+ if (help || inputs.length === 0 && !version) {
638
+ const exitCode = inputs.length === 0 ? 2 : 0;
639
+ printHelp();
640
+ process.exit(exitCode);
641
+ }
642
+ if (version) {
643
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
644
+ process.stdout.write(`${readPackageVersion(pkgPath)}
645
+ `);
646
+ process.exit(0);
647
+ }
648
+ var t0 = Date.now();
649
+ var files = await expandInputs(inputs);
650
+ if (files.length === 0) {
651
+ console.error("no files matched");
652
+ process.exit(2);
653
+ }
654
+ var reports = [];
655
+ for (const file of files) {
656
+ const report = tryCheckFile(file, mode);
657
+ reports.push(report);
658
+ printReport(file, report, quiet);
659
+ }
660
+ var failures = reports.filter((r) => r.outcome === "failed").length;
661
+ var anyChange = reports.some((r) => r.outcome === "changed");
662
+ if (bench) {
663
+ const stats = buildBenchStatsFromReports(reports, Date.now() - t0);
664
+ console.error(JSON.stringify(stats));
665
+ }
666
+ if (failures > 0) {
667
+ process.exit(2);
668
+ }
669
+ if (mode === "check") {
670
+ const exitCode = anyChange ? 1 : 0;
671
+ process.exit(exitCode);
672
+ }
@@ -0,0 +1,3 @@
1
+ export { transform } from './transform.ts';
2
+ export type { TransformOptions } from './transform.ts';
3
+ export type { TransformResult } from './types.ts';
package/dist/index.js ADDED
@@ -0,0 +1,292 @@
1
+ // src/transform/apply-edits.ts
2
+ function applyEdits(src, edits) {
3
+ const segments = [];
4
+ let tail = src.length;
5
+ for (const e of edits) {
6
+ segments.push(src.slice(e.end, tail), e.replacement);
7
+ tail = e.start;
8
+ }
9
+ segments.push(src.slice(0, tail));
10
+ return segments.toReversed().join("");
11
+ }
12
+
13
+ // src/transform/plan-gap-edit.ts
14
+ function planGapEdit(file, prev, next) {
15
+ const gap = buildGap(file, prev, next);
16
+ if (!isSafeToResize(gap)) {
17
+ return null;
18
+ }
19
+ return planBlankLineEdit(gap);
20
+ }
21
+ function buildGap(file, prev, next) {
22
+ const start = getNodeEnd(prev);
23
+ const end = getNodeStart(next);
24
+ const comments = file.comments.filter((c) => c.start >= start && c.end <= end);
25
+ return { src: file.src, start, end, comments };
26
+ }
27
+ function getNodeEnd(n) {
28
+ if (typeof n.end !== "number") {
29
+ throw new TypeError("AST node is missing an end position");
30
+ }
31
+ return n.end;
32
+ }
33
+ function getNodeStart(n) {
34
+ if (typeof n.start !== "number") {
35
+ throw new TypeError("AST node is missing a start position");
36
+ }
37
+ return n.start;
38
+ }
39
+ function isSafeToResize(gap) {
40
+ return isTriviaOnly(gap) && gap.src.slice(gap.start, gap.end).includes(`
41
+ `);
42
+ }
43
+ function isTriviaOnly(gap) {
44
+ let cursor = gap.start;
45
+ for (const c of gap.comments) {
46
+ if (gap.src.slice(cursor, c.start).trim() !== "") {
47
+ return false;
48
+ }
49
+ cursor = c.end;
50
+ }
51
+ return gap.src.slice(cursor, gap.end).trim() === "";
52
+ }
53
+ function planBlankLineEdit(gap) {
54
+ const effectiveStart = getEffectiveGapStart(gap);
55
+ const leadingComment = gap.comments.find((c) => c.start >= effectiveStart);
56
+ if (leadingComment !== undefined) {
57
+ return planLeadingCommentEdit(gap.src, effectiveStart, leadingComment.start);
58
+ }
59
+ return planWhitespaceGap(gap.src.slice(effectiveStart, gap.end), effectiveStart, gap.end);
60
+ }
61
+ function getEffectiveGapStart(gap) {
62
+ let pos = gap.start;
63
+ for (const c of gap.comments) {
64
+ if (gap.src.slice(pos, c.start).includes(`
65
+ `)) {
66
+ break;
67
+ }
68
+ pos = c.end;
69
+ }
70
+ return pos;
71
+ }
72
+ var MIN_NEWLINES = 2;
73
+ function planLeadingCommentEdit(src, restStart, commentStart) {
74
+ const leading = src.slice(restStart, commentStart);
75
+ const leadingNewlines = countNewlines(leading);
76
+ if (leadingNewlines >= MIN_NEWLINES) {
77
+ return null;
78
+ }
79
+ return {
80
+ start: restStart,
81
+ end: commentStart,
82
+ replacement: `${`
83
+ `.repeat(MIN_NEWLINES - leadingNewlines)}${leading}`
84
+ };
85
+ }
86
+ function planWhitespaceGap(gap, gapStart, gapEnd) {
87
+ if (countNewlines(gap) >= MIN_NEWLINES) {
88
+ return null;
89
+ }
90
+ const lastNL = gap.lastIndexOf(`
91
+ `);
92
+ const indent = lastNL === -1 ? "" : gap.slice(lastNL + 1);
93
+ const trimmed = gap.slice(0, gap.length - indent.length).replace(/\n*$/u, "");
94
+ const newGap = `${trimmed}${`
95
+ `.repeat(MIN_NEWLINES)}${indent}`;
96
+ return { start: gapStart, end: gapEnd, replacement: newGap };
97
+ }
98
+ function countNewlines(s) {
99
+ return (s.match(/\n/gu) ?? []).length;
100
+ }
101
+
102
+ // src/transform/build-edits-from-ast.ts
103
+ function buildEditsFromAST(src, parsed) {
104
+ const file = { src, comments: parsed.comments };
105
+ return walk(file, parsed.program).toSorted((a, b) => b.start - a.start);
106
+ }
107
+ function walk(file, node) {
108
+ const edits = [];
109
+ for (const body of getStatementLists(node)) {
110
+ edits.push(...buildPairEdits(file, node, body));
111
+ }
112
+ for (const child of collectChildNodes(node)) {
113
+ edits.push(...walk(file, child));
114
+ }
115
+ return edits;
116
+ }
117
+ function getStatementLists(node) {
118
+ const bodies = [];
119
+ const hasBlockBody = node.type === "Program" || node.type === "BlockStatement" || node.type === "ClassBody";
120
+ if (hasBlockBody && Array.isArray(node.body)) {
121
+ bodies.push(node.body);
122
+ }
123
+ if (node.type === "SwitchCase" && Array.isArray(node.consequent)) {
124
+ bodies.push(node.consequent);
125
+ }
126
+ return bodies;
127
+ }
128
+ function buildPairEdits(file, container, body) {
129
+ const edits = [];
130
+ for (let i = 0;i < body.length - 1; i++) {
131
+ const prev = body[i];
132
+ const next = body[i + 1];
133
+ if (prev !== undefined && next !== undefined && (needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next))) {
134
+ const edit = planGapEdit(file, prev, next);
135
+ if (edit !== null) {
136
+ edits.push(edit);
137
+ }
138
+ }
139
+ }
140
+ return edits;
141
+ }
142
+ function isMultiline(src, node) {
143
+ return typeof node.start === "number" && typeof node.end === "number" && src.slice(node.start, node.end).includes(`
144
+ `);
145
+ }
146
+ var CONTROL_FLOW_TYPES = new Set([
147
+ "IfStatement",
148
+ "ForStatement",
149
+ "ForInStatement",
150
+ "ForOfStatement",
151
+ "WhileStatement",
152
+ "SwitchStatement",
153
+ "TryStatement"
154
+ ]);
155
+ function needsBlankLine(container, prev, next) {
156
+ if (container.type === "ClassBody") {
157
+ return true;
158
+ }
159
+ if (isVarDecl(prev) && !isVarDecl(next)) {
160
+ return true;
161
+ }
162
+ if (next.type === "ReturnStatement") {
163
+ return true;
164
+ }
165
+ if (isFnOrClassDecl(prev)) {
166
+ return true;
167
+ }
168
+ return isNewHeaded(prev) !== isNewHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
169
+ }
170
+ function isNewHeaded(node) {
171
+ return getStatementValues(node).some((value) => getExpressionHead(value).type === "NewExpression");
172
+ }
173
+ function getStatementValues(node) {
174
+ if (node.type === "VariableDeclaration") {
175
+ return collectASTNodes(node["declarations"]).map((declarator) => declarator["init"]).filter((init) => isASTNode(init));
176
+ }
177
+ const { expression } = node;
178
+ if (node.type !== "ExpressionStatement" || !isASTNode(expression)) {
179
+ return [];
180
+ }
181
+ const { right } = expression;
182
+ if (expression.type === "AssignmentExpression" && isASTNode(right)) {
183
+ return [right];
184
+ }
185
+ return [expression];
186
+ }
187
+ var HEAD_PROPERTY = {
188
+ CallExpression: "callee",
189
+ MemberExpression: "object",
190
+ ChainExpression: "expression",
191
+ AwaitExpression: "argument",
192
+ TSNonNullExpression: "expression",
193
+ ParenthesizedExpression: "expression"
194
+ };
195
+ function getExpressionHead(node) {
196
+ let current = node;
197
+ let property = HEAD_PROPERTY[current.type];
198
+ while (property !== undefined) {
199
+ const inner = current[property];
200
+ if (!isASTNode(inner)) {
201
+ break;
202
+ }
203
+ current = inner;
204
+ property = HEAD_PROPERTY[current.type];
205
+ }
206
+ return current;
207
+ }
208
+ var CALL_TYPES = ["CallExpression", "AwaitExpression"];
209
+ var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
210
+ function isKindBoundary(prev, next) {
211
+ const prevKind = getStatementKind(prev);
212
+ const nextKind = getStatementKind(next);
213
+ return prevKind !== null && nextKind !== null && prevKind !== nextKind;
214
+ }
215
+ function getStatementKind(node) {
216
+ if (isVarDecl(node)) {
217
+ return "declaration";
218
+ }
219
+ if (isExpressionStatementOf(node, CALL_TYPES)) {
220
+ return "call";
221
+ }
222
+ return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
223
+ }
224
+ function isExpressionStatementOf(node, types) {
225
+ const { expression } = node;
226
+ return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
227
+ }
228
+ var VAR_DECL_KINDS = new Set(["const", "let", "var"]);
229
+ function isVarDecl(node) {
230
+ if (node.type === "VariableDeclaration") {
231
+ return node.kind !== undefined && VAR_DECL_KINDS.has(node.kind);
232
+ }
233
+ return false;
234
+ }
235
+ function isFnOrClassDecl(node) {
236
+ return node.type === "FunctionDeclaration" || node.type === "ClassDeclaration";
237
+ }
238
+ function collectChildNodes(node) {
239
+ const children = [];
240
+ for (const key of Object.keys(node)) {
241
+ if (key !== "loc" && key !== "parent") {
242
+ children.push(...collectASTNodes(node[key]));
243
+ }
244
+ }
245
+ return children;
246
+ }
247
+ function collectASTNodes(value) {
248
+ if (!Array.isArray(value)) {
249
+ return isASTNode(value) ? [value] : [];
250
+ }
251
+ const nodes = [];
252
+ for (const item of value) {
253
+ if (isASTNode(item)) {
254
+ nodes.push(item);
255
+ }
256
+ }
257
+ return nodes;
258
+ }
259
+ function isASTNode(value) {
260
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
261
+ }
262
+
263
+ // src/transform/parse-source.ts
264
+ import { parseSync } from "oxc-parser";
265
+ function parseSource(src, filename) {
266
+ const parsed = parseSync(filename, src);
267
+ const [firstError] = parsed.errors;
268
+ if (firstError !== undefined) {
269
+ return firstError.message;
270
+ }
271
+ if (!isASTNode2(parsed.program)) {
272
+ throw new TypeError("oxc-parser returned a malformed program node");
273
+ }
274
+ return { program: parsed.program, comments: parsed.comments };
275
+ }
276
+ function isASTNode2(value) {
277
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
278
+ }
279
+
280
+ // src/transform.ts
281
+ function transform(src, options) {
282
+ const parsed = parseSource(src, options?.filename ?? "source.ts");
283
+ if (typeof parsed === "string") {
284
+ return { output: src, edits: 0, parseError: parsed };
285
+ }
286
+ const editList = buildEditsFromAST(src, parsed);
287
+ const output = applyEdits(src, editList);
288
+ return { output, edits: editList.length, parseError: null };
289
+ }
290
+ export {
291
+ transform
292
+ };
@@ -0,0 +1,7 @@
1
+ import type { Edit } from '../types.ts';
2
+ /**
3
+ * Callers must pass edits sorted last-to-first, so each splice's offsets stay
4
+ * valid without adjustment. Segments are collected and joined once instead of
5
+ * re-copying the whole string per edit.
6
+ */
7
+ export declare function applyEdits(src: string, edits: readonly Edit[]): string;
@@ -0,0 +1,5 @@
1
+ import type { Edit, ParsedSource } from '../types.ts';
2
+ /**
3
+ * Sorted last-to-first so applying splices in order never shifts later offsets.
4
+ */
5
+ export declare function buildEditsFromAST(src: string, parsed: ParsedSource): Edit[];
@@ -0,0 +1,10 @@
1
+ import type { ParsedSource } from '../types.ts';
2
+ /**
3
+ * The filename picks the dialect — `.tsx` enables JSX, anything else parses as
4
+ * plain TypeScript, so `.ts`-only syntax like `<string>value` assertions and
5
+ * un-comma'd generic arrows parse correctly. oxc reports syntax errors as a
6
+ * list instead of throwing; any error means node offsets can't be trusted for
7
+ * splicing, so the first message is returned and the caller skips the file.
8
+ * ParsedSource is always an object, so the string return is unambiguous.
9
+ */
10
+ export declare function parseSource(src: string, filename: string): ParsedSource | string;
@@ -0,0 +1,8 @@
1
+ import type { ASTNode, Edit, SourceFile } from '../types.ts';
2
+ /**
3
+ * 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.
7
+ */
8
+ export declare function planGapEdit(file: SourceFile, prev: ASTNode, next: ASTNode): Edit | null;
@@ -0,0 +1,13 @@
1
+ import type { TransformResult } from './types.ts';
2
+ export interface TransformOptions {
3
+ /**
4
+ * Picks the parse dialect: `.tsx` enables JSX, anything else is plain
5
+ * TypeScript. Defaults to 'source.ts' — JSX callers must say so.
6
+ */
7
+ readonly filename?: string;
8
+ }
9
+ /**
10
+ * Pure transform: source string in, edited source string out, no I/O. On a
11
+ * parse error the input is returned untouched alongside the error message.
12
+ */
13
+ export declare function transform(src: string, options?: TransformOptions): TransformResult;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The fields of the oxc AST this transform reads; the index signature carries
3
+ * every other node property so the generic child walk can recurse untyped. The
4
+ * codemod never mutates the tree, so everything is readonly.
5
+ */
6
+ export interface ASTNode {
7
+ readonly type: string;
8
+ readonly kind?: string;
9
+ readonly start?: number;
10
+ readonly end?: number;
11
+ readonly body?: readonly ASTNode[] | ASTNode;
12
+ readonly consequent?: readonly ASTNode[];
13
+ readonly declaration?: ASTNode;
14
+ readonly [key: string]: unknown;
15
+ }
16
+ export interface CommentSpan {
17
+ readonly start: number;
18
+ readonly end: number;
19
+ }
20
+ export interface ParsedSource {
21
+ readonly program: ASTNode;
22
+ readonly comments: readonly CommentSpan[];
23
+ }
24
+ /**
25
+ * The raw text and its comment spans together — what gap planning needs to
26
+ * classify inter-statement trivia without re-lexing.
27
+ */
28
+ export interface SourceFile {
29
+ readonly src: string;
30
+ readonly comments: readonly CommentSpan[];
31
+ }
32
+ export interface Edit {
33
+ readonly start: number;
34
+ readonly end: number;
35
+ readonly replacement: string;
36
+ }
37
+ export interface TransformResult {
38
+ readonly output: string;
39
+ readonly edits: number;
40
+ readonly parseError: string | null;
41
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@zgeoff/format-codemod",
3
+ "version": "0.0.1",
4
+ "description": "Enforce blank-line padding conventions in TypeScript via a fast text-splice codemod",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/tools.git",
9
+ "directory": "packages/format-codemod"
10
+ },
11
+ "bin": {
12
+ "format-codemod": "./dist/cli.js"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "type": "module",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --packages external && tsc -p tsconfig.build.json",
29
+ "prepare": "bun run build",
30
+ "typecheck": "tsc"
31
+ },
32
+ "dependencies": {
33
+ "oxc-parser": "0.137.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "1.3.14",
37
+ "typescript": "7.0.1-rc"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.3",
41
+ "node": ">=22"
42
+ }
43
+ }