@alexgorbatchev/typescript-ai-policy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +223 -0
  2. package/package.json +60 -0
  3. package/src/oxfmt/createOxfmtConfig.ts +26 -0
  4. package/src/oxlint/assertNoRuleCollisions.ts +40 -0
  5. package/src/oxlint/createOxlintConfig.ts +161 -0
  6. package/src/oxlint/oxlint.config.ts +3 -0
  7. package/src/oxlint/plugin.ts +90 -0
  8. package/src/oxlint/rules/component-directory-file-convention.ts +65 -0
  9. package/src/oxlint/rules/component-file-contract.ts +328 -0
  10. package/src/oxlint/rules/component-file-location-convention.ts +43 -0
  11. package/src/oxlint/rules/component-file-naming-convention.ts +260 -0
  12. package/src/oxlint/rules/component-story-file-convention.ts +108 -0
  13. package/src/oxlint/rules/fixture-export-naming-convention.ts +72 -0
  14. package/src/oxlint/rules/fixture-export-type-contract.ts +264 -0
  15. package/src/oxlint/rules/fixture-file-contract.ts +91 -0
  16. package/src/oxlint/rules/fixture-import-path-convention.ts +125 -0
  17. package/src/oxlint/rules/helpers.ts +544 -0
  18. package/src/oxlint/rules/hook-export-location-convention.ts +169 -0
  19. package/src/oxlint/rules/hook-file-contract.ts +179 -0
  20. package/src/oxlint/rules/hook-file-naming-convention.ts +151 -0
  21. package/src/oxlint/rules/hook-test-file-convention.ts +60 -0
  22. package/src/oxlint/rules/hooks-directory-file-convention.ts +75 -0
  23. package/src/oxlint/rules/index-file-contract.ts +177 -0
  24. package/src/oxlint/rules/interface-naming-convention.ts +72 -0
  25. package/src/oxlint/rules/no-conditional-logic-in-tests.ts +53 -0
  26. package/src/oxlint/rules/no-fixture-exports-outside-fixture-entrypoint.ts +68 -0
  27. package/src/oxlint/rules/no-imports-from-tests-directory.ts +114 -0
  28. package/src/oxlint/rules/no-inline-fixture-bindings-in-tests.ts +54 -0
  29. package/src/oxlint/rules/no-inline-type-expressions.ts +169 -0
  30. package/src/oxlint/rules/no-local-type-declarations-in-fixture-files.ts +55 -0
  31. package/src/oxlint/rules/no-module-mocking.ts +85 -0
  32. package/src/oxlint/rules/no-non-running-tests.ts +72 -0
  33. package/src/oxlint/rules/no-react-create-element.ts +59 -0
  34. package/src/oxlint/rules/no-test-file-exports.ts +52 -0
  35. package/src/oxlint/rules/no-throw-in-tests.ts +40 -0
  36. package/src/oxlint/rules/no-type-exports-from-constants.ts +97 -0
  37. package/src/oxlint/rules/no-type-imports-from-constants.ts +73 -0
  38. package/src/oxlint/rules/no-value-exports-from-types.ts +115 -0
  39. package/src/oxlint/rules/require-component-root-testid.ts +547 -0
  40. package/src/oxlint/rules/require-template-indent.ts +83 -0
  41. package/src/oxlint/rules/single-fixture-entrypoint.ts +142 -0
  42. package/src/oxlint/rules/stories-directory-file-convention.ts +55 -0
  43. package/src/oxlint/rules/story-export-contract.ts +343 -0
  44. package/src/oxlint/rules/story-file-location-convention.ts +64 -0
  45. package/src/oxlint/rules/story-meta-type-annotation.ts +129 -0
  46. package/src/oxlint/rules/test-file-location-convention.ts +115 -0
  47. package/src/oxlint/rules/testid-naming-convention.ts +63 -0
  48. package/src/oxlint/rules/tests-directory-file-convention.ts +55 -0
  49. package/src/oxlint/rules/types.ts +45 -0
  50. package/src/semantic-fixes/applyFileChanges.ts +81 -0
  51. package/src/semantic-fixes/applySemanticFixes.ts +239 -0
  52. package/src/semantic-fixes/applyTextEdits.ts +164 -0
  53. package/src/semantic-fixes/backends/tsgo-lsp/TsgoLspClient.ts +439 -0
  54. package/src/semantic-fixes/backends/tsgo-lsp/createTsgoLspSemanticFixBackend.ts +251 -0
  55. package/src/semantic-fixes/providers/createInterfaceNamingConventionSemanticFixProvider.ts +132 -0
  56. package/src/semantic-fixes/providers/createTestFileLocationConventionSemanticFixProvider.ts +52 -0
  57. package/src/semantic-fixes/readMovedFileTextEdits.ts +150 -0
  58. package/src/semantic-fixes/readSemanticFixRuntimePaths.ts +38 -0
  59. package/src/semantic-fixes/runApplySemanticFixes.ts +120 -0
  60. package/src/semantic-fixes/runOxlintJson.ts +139 -0
  61. package/src/semantic-fixes/types.ts +163 -0
  62. package/src/shared/mergeConfig.ts +38 -0
@@ -0,0 +1,132 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import ts from "typescript";
4
+ import type {
5
+ IOxlintDiagnostic,
6
+ ISemanticFixOperation,
7
+ ISemanticFixProvider,
8
+ ISemanticFixProviderContext,
9
+ } from "../types.ts";
10
+
11
+ function readAbsoluteDiagnosticFilePath(diagnostic: IOxlintDiagnostic, context: ISemanticFixProviderContext): string {
12
+ if (isAbsolute(diagnostic.filename)) {
13
+ return diagnostic.filename;
14
+ }
15
+
16
+ return resolve(context.targetDirectoryPath, diagnostic.filename);
17
+ }
18
+
19
+ function readInterfaceDeclarationAtOffset(node: ts.Node, offset: number): ts.InterfaceDeclaration | null {
20
+ if (ts.isInterfaceDeclaration(node)) {
21
+ const start = node.name.getStart();
22
+ const end = node.name.getEnd();
23
+ if (offset >= start && offset <= end) {
24
+ return node;
25
+ }
26
+ }
27
+
28
+ let matchingDeclaration: ts.InterfaceDeclaration | null = null;
29
+
30
+ ts.forEachChild(node, (childNode) => {
31
+ if (matchingDeclaration) {
32
+ return;
33
+ }
34
+
35
+ matchingDeclaration = readInterfaceDeclarationAtOffset(childNode, offset);
36
+ });
37
+
38
+ return matchingDeclaration;
39
+ }
40
+
41
+ function readOffsetFromLineAndColumn(sourceFile: ts.SourceFile, line: number, column: number): number | null {
42
+ if (line < 1 || column < 1) {
43
+ return null;
44
+ }
45
+
46
+ try {
47
+ return ts.getPositionOfLineAndCharacter(sourceFile, line - 1, column - 1);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function readInterfaceDeclarationFromLabel(
54
+ sourceFile: ts.SourceFile,
55
+ label: IOxlintDiagnostic["labels"][number],
56
+ ): ts.InterfaceDeclaration | null {
57
+ const declarationAtReportedOffset = readInterfaceDeclarationAtOffset(sourceFile, label.span.offset);
58
+ if (declarationAtReportedOffset) {
59
+ return declarationAtReportedOffset;
60
+ }
61
+
62
+ const offsetFromLineAndColumn = readOffsetFromLineAndColumn(sourceFile, label.span.line, label.span.column);
63
+ if (offsetFromLineAndColumn === null || offsetFromLineAndColumn === label.span.offset) {
64
+ return null;
65
+ }
66
+
67
+ return readInterfaceDeclarationAtOffset(sourceFile, offsetFromLineAndColumn);
68
+ }
69
+
70
+ function readNormalizedInterfaceName(interfaceName: string): string | null {
71
+ const rawBaseName = /^[Ii]/.test(interfaceName) ? interfaceName.slice(1) : interfaceName;
72
+ if (!/^[A-Za-z][A-Za-z0-9]*$/.test(rawBaseName)) {
73
+ return null;
74
+ }
75
+
76
+ const normalizedBaseName = rawBaseName.charAt(0).toUpperCase() + rawBaseName.slice(1);
77
+ const normalizedInterfaceName = `I${normalizedBaseName}`;
78
+
79
+ if (normalizedInterfaceName === interfaceName) {
80
+ return null;
81
+ }
82
+
83
+ return normalizedInterfaceName;
84
+ }
85
+
86
+ function readOperation(
87
+ diagnostic: IOxlintDiagnostic,
88
+ context: ISemanticFixProviderContext,
89
+ ): ISemanticFixOperation | null {
90
+ const label = diagnostic.labels[0];
91
+ if (!label) {
92
+ return null;
93
+ }
94
+
95
+ const filePath = readAbsoluteDiagnosticFilePath(diagnostic, context);
96
+ const content = readFileSync(filePath, "utf8");
97
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
98
+ const interfaceDeclaration = readInterfaceDeclarationFromLabel(sourceFile, label);
99
+ if (!interfaceDeclaration) {
100
+ return null;
101
+ }
102
+
103
+ const symbolName = interfaceDeclaration.name.text;
104
+ const newName = readNormalizedInterfaceName(symbolName);
105
+ if (!newName) {
106
+ return null;
107
+ }
108
+
109
+ const start = ts.getLineAndCharacterOfPosition(sourceFile, interfaceDeclaration.name.getStart());
110
+
111
+ return {
112
+ filePath,
113
+ id: `${diagnostic.code}:${filePath}:${start.line}:${start.character}:${newName}`,
114
+ kind: "rename-symbol",
115
+ newName,
116
+ position: {
117
+ character: start.character,
118
+ line: start.line,
119
+ },
120
+ ruleCode: diagnostic.code,
121
+ symbolName,
122
+ };
123
+ }
124
+
125
+ export function createInterfaceNamingConventionSemanticFixProvider(): ISemanticFixProvider {
126
+ return {
127
+ createOperation(diagnostic, context): ISemanticFixOperation | null {
128
+ return readOperation(diagnostic, context);
129
+ },
130
+ ruleCode: "@alexgorbatchev/interface-naming-convention",
131
+ };
132
+ }
@@ -0,0 +1,52 @@
1
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
2
+ import type {
3
+ IOxlintDiagnostic,
4
+ ISemanticFixOperation,
5
+ ISemanticFixProvider,
6
+ ISemanticFixProviderContext,
7
+ } from "../types.ts";
8
+
9
+ const REQUIRED_TEST_FILE_NAME_PATTERN = /\.test\.tsx?$/u;
10
+ const TESTS_DIRECTORY_PATH_PATTERN = /(^|\/)__tests__(\/|$)/u;
11
+
12
+ function readAbsoluteDiagnosticFilePath(diagnostic: IOxlintDiagnostic, context: ISemanticFixProviderContext): string {
13
+ if (isAbsolute(diagnostic.filename)) {
14
+ return diagnostic.filename;
15
+ }
16
+
17
+ return resolve(context.targetDirectoryPath, diagnostic.filename);
18
+ }
19
+
20
+ function isInTestsDirectory(filePath: string): boolean {
21
+ return TESTS_DIRECTORY_PATH_PATTERN.test(filePath.replaceAll("\\", "/"));
22
+ }
23
+
24
+ function readOperation(
25
+ diagnostic: IOxlintDiagnostic,
26
+ context: ISemanticFixProviderContext,
27
+ ): ISemanticFixOperation | null {
28
+ const filePath = readAbsoluteDiagnosticFilePath(diagnostic, context);
29
+ const baseName = basename(filePath);
30
+ if (!REQUIRED_TEST_FILE_NAME_PATTERN.test(baseName) || isInTestsDirectory(filePath)) {
31
+ return null;
32
+ }
33
+
34
+ const newFilePath = join(dirname(filePath), "__tests__", baseName);
35
+
36
+ return {
37
+ filePath,
38
+ id: `${diagnostic.code}:${filePath}:${newFilePath}`,
39
+ kind: "move-file",
40
+ newFilePath,
41
+ ruleCode: diagnostic.code,
42
+ };
43
+ }
44
+
45
+ export function createTestFileLocationConventionSemanticFixProvider(): ISemanticFixProvider {
46
+ return {
47
+ createOperation(diagnostic, context): ISemanticFixOperation | null {
48
+ return readOperation(diagnostic, context);
49
+ },
50
+ ruleCode: "@alexgorbatchev/test-file-location-convention",
51
+ };
52
+ }
@@ -0,0 +1,150 @@
1
+ import { dirname, relative, resolve } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import ts from "typescript";
4
+ import type { ITextEdit } from "./types.ts";
5
+
6
+ function isRelativeModuleSpecifier(moduleSpecifier: string): boolean {
7
+ return (
8
+ moduleSpecifier === "." ||
9
+ moduleSpecifier === ".." ||
10
+ moduleSpecifier.startsWith("./") ||
11
+ moduleSpecifier.startsWith("../")
12
+ );
13
+ }
14
+
15
+ function normalizePathSlashes(filePath: string): string {
16
+ return filePath.replaceAll("\\", "/");
17
+ }
18
+
19
+ function readRelativeModuleSpecifier(fromDirectoryPath: string, targetPath: string): string {
20
+ const relativePath = normalizePathSlashes(relative(fromDirectoryPath, targetPath));
21
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
22
+ }
23
+
24
+ type IModuleSpecifierEntry = {
25
+ moduleSpecifier: string;
26
+ node: ts.StringLiteralLike;
27
+ };
28
+
29
+ function readImportTypeArgumentLiteral(node: ts.ImportTypeNode): ts.StringLiteralLike | null {
30
+ if (!ts.isLiteralTypeNode(node.argument)) {
31
+ return null;
32
+ }
33
+
34
+ return ts.isStringLiteralLike(node.argument.literal) ? node.argument.literal : null;
35
+ }
36
+
37
+ function readModuleSpecifierEntry(node: ts.Node): IModuleSpecifierEntry | null {
38
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
39
+ return node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)
40
+ ? {
41
+ moduleSpecifier: node.moduleSpecifier.text,
42
+ node: node.moduleSpecifier,
43
+ }
44
+ : null;
45
+ }
46
+
47
+ if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
48
+ const expression = node.moduleReference.expression;
49
+ return expression && ts.isStringLiteralLike(expression)
50
+ ? {
51
+ moduleSpecifier: expression.text,
52
+ node: expression,
53
+ }
54
+ : null;
55
+ }
56
+
57
+ if (ts.isImportTypeNode(node)) {
58
+ const argumentLiteral = readImportTypeArgumentLiteral(node);
59
+ return argumentLiteral
60
+ ? {
61
+ moduleSpecifier: argumentLiteral.text,
62
+ node: argumentLiteral,
63
+ }
64
+ : null;
65
+ }
66
+
67
+ if (ts.isCallExpression(node)) {
68
+ const firstArgument = node.arguments[0];
69
+ if (!firstArgument || !ts.isStringLiteralLike(firstArgument)) {
70
+ return null;
71
+ }
72
+
73
+ if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
74
+ return {
75
+ moduleSpecifier: firstArgument.text,
76
+ node: firstArgument,
77
+ };
78
+ }
79
+
80
+ if (ts.isIdentifier(node.expression) && node.expression.text === "require") {
81
+ return {
82
+ moduleSpecifier: firstArgument.text,
83
+ node: firstArgument,
84
+ };
85
+ }
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ function readModuleSpecifierEntries(sourceFile: ts.SourceFile): readonly IModuleSpecifierEntry[] {
92
+ const moduleSpecifierEntries: IModuleSpecifierEntry[] = [];
93
+
94
+ function visitNode(node: ts.Node): void {
95
+ const moduleSpecifierEntry = readModuleSpecifierEntry(node);
96
+ if (moduleSpecifierEntry) {
97
+ moduleSpecifierEntries.push(moduleSpecifierEntry);
98
+ }
99
+
100
+ ts.forEachChild(node, visitNode);
101
+ }
102
+
103
+ visitNode(sourceFile);
104
+
105
+ return moduleSpecifierEntries;
106
+ }
107
+
108
+ export function readMovedFileTextEdits(sourceFilePath: string, destinationFilePath: string): readonly ITextEdit[] {
109
+ const content = readFileSync(sourceFilePath, "utf8");
110
+ const sourceFile = ts.createSourceFile(sourceFilePath, content, ts.ScriptTarget.Latest, true);
111
+ const sourceDirectoryPath = dirname(sourceFilePath);
112
+ const destinationDirectoryPath = dirname(destinationFilePath);
113
+ const textEdits: ITextEdit[] = [];
114
+
115
+ for (const moduleSpecifierEntry of readModuleSpecifierEntries(sourceFile)) {
116
+ if (!isRelativeModuleSpecifier(moduleSpecifierEntry.moduleSpecifier)) {
117
+ continue;
118
+ }
119
+
120
+ const resolvedImportTargetPath = resolve(sourceDirectoryPath, moduleSpecifierEntry.moduleSpecifier);
121
+ const updatedModuleSpecifier = readRelativeModuleSpecifier(destinationDirectoryPath, resolvedImportTargetPath);
122
+ if (updatedModuleSpecifier === moduleSpecifierEntry.moduleSpecifier) {
123
+ continue;
124
+ }
125
+
126
+ const start = ts.getLineAndCharacterOfPosition(sourceFile, moduleSpecifierEntry.node.getStart(sourceFile) + 1);
127
+ const end = ts.getLineAndCharacterOfPosition(sourceFile, moduleSpecifierEntry.node.getEnd() - 1);
128
+
129
+ textEdits.push({
130
+ end: {
131
+ character: end.character,
132
+ line: end.line,
133
+ },
134
+ filePath: sourceFilePath,
135
+ newText: updatedModuleSpecifier,
136
+ start: {
137
+ character: start.character,
138
+ line: start.line,
139
+ },
140
+ });
141
+ }
142
+
143
+ return textEdits.sort((left, right) => {
144
+ if (left.start.line !== right.start.line) {
145
+ return left.start.line - right.start.line;
146
+ }
147
+
148
+ return left.start.character - right.start.character;
149
+ });
150
+ }
@@ -0,0 +1,38 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export type ISemanticFixRuntimePaths = {
6
+ oxlintConfigPath: string;
7
+ oxlintExecutablePath: string;
8
+ tsgoExecutablePath: string;
9
+ };
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const packageRootPath = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
13
+
14
+ function readInstalledPackageRootPath(packageName: string, resolutionFailureMessage: string): string {
15
+ try {
16
+ const packageJsonPath = require.resolve(`${packageName}/package.json`);
17
+ return dirname(packageJsonPath);
18
+ } catch {
19
+ throw new Error(resolutionFailureMessage);
20
+ }
21
+ }
22
+
23
+ export function readSemanticFixRuntimePaths(): ISemanticFixRuntimePaths {
24
+ const oxlintPackageRootPath = readInstalledPackageRootPath(
25
+ "oxlint",
26
+ 'Missing peer dependency "oxlint". Install oxlint in the consuming project so the semantic-fix CLI can run repository policy checks.',
27
+ );
28
+ const tsgoPackageRootPath = readInstalledPackageRootPath(
29
+ "@typescript/native-preview",
30
+ 'Missing peer dependency "@typescript/native-preview". Install @typescript/native-preview in the consuming project to use the semantic-fix CLI.',
31
+ );
32
+
33
+ return {
34
+ oxlintConfigPath: resolve(packageRootPath, "src/oxlint/oxlint.config.ts"),
35
+ oxlintExecutablePath: resolve(oxlintPackageRootPath, "bin/oxlint"),
36
+ tsgoExecutablePath: resolve(tsgoPackageRootPath, "bin/tsgo.js"),
37
+ };
38
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync, statSync } from "node:fs";
4
+ import { isAbsolute, relative, resolve } from "node:path";
5
+ import { applySemanticFixes } from "./applySemanticFixes.ts";
6
+ import { readSemanticFixRuntimePaths } from "./readSemanticFixRuntimePaths.ts";
7
+ import type { IApplySemanticFixesProgressEvent, ISkippedDiagnostic } from "./types.ts";
8
+
9
+ type ICliOptions = {
10
+ dryRun: boolean;
11
+ targetDirectoryPath: string;
12
+ };
13
+
14
+ const SEMANTIC_FIX_BIN_NAME = "typescript-ai-policy-fix-semantic";
15
+
16
+ function readUsageText(): string {
17
+ return [
18
+ "Usage:",
19
+ ` ${SEMANTIC_FIX_BIN_NAME} <target-directory> [--dry-run]`,
20
+ " bun run fix:semantic -- <target-directory> [--dry-run]",
21
+ "",
22
+ "Examples:",
23
+ ` ${SEMANTIC_FIX_BIN_NAME} .`,
24
+ ` ${SEMANTIC_FIX_BIN_NAME} /path/to/project --dry-run`,
25
+ " bun run fix:semantic -- .",
26
+ ].join("\n");
27
+ }
28
+
29
+ function readCliOptions(argv: readonly string[]): ICliOptions {
30
+ const remainingArguments = argv.slice(2);
31
+ const targetDirectoryArgument = remainingArguments.find((argument) => !argument.startsWith("-"));
32
+ if (!targetDirectoryArgument) {
33
+ throw new Error(readUsageText());
34
+ }
35
+
36
+ const targetDirectoryPath = resolve(targetDirectoryArgument);
37
+ if (!existsSync(targetDirectoryPath)) {
38
+ throw new Error(`Target directory does not exist: ${targetDirectoryPath}`);
39
+ }
40
+
41
+ if (!statSync(targetDirectoryPath).isDirectory()) {
42
+ throw new Error(`Target path is not a directory: ${targetDirectoryPath}`);
43
+ }
44
+
45
+ return {
46
+ dryRun: remainingArguments.includes("--dry-run"),
47
+ targetDirectoryPath,
48
+ };
49
+ }
50
+
51
+ function readDisplayPath(targetDirectoryPath: string, filePath: string): string {
52
+ const absoluteFilePath = isAbsolute(filePath) ? filePath : resolve(targetDirectoryPath, filePath);
53
+ const relativeFilePath = relative(targetDirectoryPath, absoluteFilePath);
54
+ return relativeFilePath.length > 0 ? relativeFilePath : ".";
55
+ }
56
+
57
+ function formatSkippedDiagnostic(targetDirectoryPath: string, skippedDiagnostic: ISkippedDiagnostic): string {
58
+ return `- [${skippedDiagnostic.ruleCode}] ${readDisplayPath(targetDirectoryPath, skippedDiagnostic.filePath)}: ${skippedDiagnostic.reason}`;
59
+ }
60
+
61
+ function formatProgressEvent(event: IApplySemanticFixesProgressEvent): string {
62
+ switch (event.kind) {
63
+ case "running-oxlint": {
64
+ return "running oxlint...";
65
+ }
66
+ case "collected-diagnostics": {
67
+ return `semantic-fix diagnostics: ${String(event.diagnosticCount)}`;
68
+ }
69
+ case "planning-start": {
70
+ return `planning semantic fixes: ${String(event.operationCount)} candidate operation(s)`;
71
+ }
72
+ case "planning-operation": {
73
+ return `planning semantic fix ${String(event.operationIndex)}/${String(event.operationCount)}: ${event.description}`;
74
+ }
75
+ case "applying-file-changes": {
76
+ const modeLabel = event.dryRun ? "dry run" : "applying changes";
77
+ return `${modeLabel}: ${String(event.textEditCount)} text edit(s) and ${String(event.moveCount)} file move(s) across ${String(event.fileCount)} file(s)`;
78
+ }
79
+ case "complete": {
80
+ return `semantic fix complete: ${String(event.plannedFixCount)} plan(s), ${String(event.changedFileCount)} changed file(s), ${String(event.skippedDiagnosticCount)} skipped diagnostic(s)`;
81
+ }
82
+ }
83
+ }
84
+
85
+ try {
86
+ const cliOptions = readCliOptions(process.argv);
87
+ const runtimePaths = readSemanticFixRuntimePaths();
88
+ const result = await applySemanticFixes({
89
+ dryRun: cliOptions.dryRun,
90
+ onProgress(event) {
91
+ console.log(formatProgressEvent(event));
92
+ },
93
+ oxlintConfigPath: runtimePaths.oxlintConfigPath,
94
+ oxlintExecutablePath: runtimePaths.oxlintExecutablePath,
95
+ targetDirectoryPath: cliOptions.targetDirectoryPath,
96
+ tsgoExecutablePath: runtimePaths.tsgoExecutablePath,
97
+ });
98
+
99
+ console.log(`backend: ${result.backendName}`);
100
+ console.log(`planned fixes: ${String(result.plannedFixCount)}`);
101
+ console.log(`applied files: ${String(result.appliedFileCount)}`);
102
+
103
+ if (result.changedFilePaths.length > 0) {
104
+ console.log("changed files:");
105
+ for (const changedFilePath of result.changedFilePaths) {
106
+ console.log(`- ${readDisplayPath(cliOptions.targetDirectoryPath, changedFilePath)}`);
107
+ }
108
+ }
109
+
110
+ if (result.skippedDiagnostics.length > 0) {
111
+ console.log("skipped diagnostics:");
112
+ for (const skippedDiagnostic of result.skippedDiagnostics) {
113
+ console.log(formatSkippedDiagnostic(cliOptions.targetDirectoryPath, skippedDiagnostic));
114
+ }
115
+ }
116
+ } catch (error) {
117
+ const errorMessage = error instanceof Error ? error.message : String(error);
118
+ console.error(errorMessage);
119
+ process.exitCode = 1;
120
+ }
@@ -0,0 +1,139 @@
1
+ import { closeSync, openSync } from "node:fs";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { spawn } from "node:child_process";
6
+ import type { IOxlintDiagnostic } from "./types.ts";
7
+
8
+ type IRunOxlintJsonOptions = {
9
+ oxlintConfigPath: string;
10
+ oxlintExecutablePath: string;
11
+ targetDirectoryPath: string;
12
+ };
13
+
14
+ type IOxlintProcessCompletion = {
15
+ exitCode: number | null;
16
+ signal: NodeJS.Signals | null;
17
+ };
18
+
19
+ type IOxlintProcessResult = IOxlintProcessCompletion & {
20
+ stderr: string;
21
+ stdout: string;
22
+ };
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null;
26
+ }
27
+
28
+ function isOxlintDiagnostic(value: unknown): value is IOxlintDiagnostic {
29
+ if (!isRecord(value)) {
30
+ return false;
31
+ }
32
+
33
+ return (
34
+ typeof value.code === "string" &&
35
+ typeof value.filename === "string" &&
36
+ Array.isArray(value.labels) &&
37
+ typeof value.message === "string" &&
38
+ typeof value.severity === "string"
39
+ );
40
+ }
41
+
42
+ function readNormalizedRuleCode(ruleCode: string): string {
43
+ const normalizedRuleCodeMatch = /^(@[^()]+)\(([^()]+)\)$/.exec(ruleCode);
44
+ if (!normalizedRuleCodeMatch) {
45
+ return ruleCode;
46
+ }
47
+
48
+ const [, pluginName, localRuleName] = normalizedRuleCodeMatch;
49
+ return `${pluginName}/${localRuleName}`;
50
+ }
51
+
52
+ function readDiagnostics(report: unknown): readonly IOxlintDiagnostic[] {
53
+ if (!isRecord(report) || !Array.isArray(report.diagnostics)) {
54
+ throw new Error(`Unexpected Oxlint JSON output: ${JSON.stringify(report)}`);
55
+ }
56
+
57
+ const diagnostics = report.diagnostics.filter(isOxlintDiagnostic);
58
+ if (diagnostics.length !== report.diagnostics.length) {
59
+ throw new Error(`Unexpected Oxlint diagnostic payload: ${JSON.stringify(report)}`);
60
+ }
61
+
62
+ return diagnostics.map((diagnostic) => ({
63
+ ...diagnostic,
64
+ code: readNormalizedRuleCode(diagnostic.code),
65
+ }));
66
+ }
67
+
68
+ async function runOxlintProcess(options: IRunOxlintJsonOptions): Promise<IOxlintProcessResult> {
69
+ const tempDirectoryPath = await mkdtemp(join(tmpdir(), "semantic-fixes-oxlint-"));
70
+ const stdoutPath = join(tempDirectoryPath, "oxlint-report.json");
71
+ const stdoutFileDescriptor = openSync(stdoutPath, "w");
72
+
73
+ try {
74
+ let childProcess: ReturnType<typeof spawn>;
75
+
76
+ try {
77
+ childProcess = spawn(
78
+ options.oxlintExecutablePath,
79
+ ["--config", options.oxlintConfigPath, "--disable-nested-config", "--format", "json", "."],
80
+ {
81
+ cwd: options.targetDirectoryPath,
82
+ stdio: ["ignore", stdoutFileDescriptor, "pipe"],
83
+ },
84
+ );
85
+ } finally {
86
+ closeSync(stdoutFileDescriptor);
87
+ }
88
+
89
+ const stderrChunks: string[] = [];
90
+ const stderrStream = childProcess.stderr;
91
+ if (!stderrStream) {
92
+ throw new Error("Oxlint stderr stream is unavailable.");
93
+ }
94
+
95
+ stderrStream.on("data", (chunk: Buffer) => {
96
+ stderrChunks.push(chunk.toString("utf8"));
97
+ });
98
+
99
+ const processCompletion = await new Promise<IOxlintProcessCompletion>((resolve, reject) => {
100
+ childProcess.once("error", (error: Error) => {
101
+ reject(error);
102
+ });
103
+ childProcess.once("close", (exitCode: number | null, signal: NodeJS.Signals | null) => {
104
+ resolve({ exitCode, signal });
105
+ });
106
+ });
107
+
108
+ const stdout = await readFile(stdoutPath, "utf8");
109
+
110
+ return {
111
+ exitCode: processCompletion.exitCode,
112
+ signal: processCompletion.signal,
113
+ stderr: stderrChunks.join("").trim(),
114
+ stdout,
115
+ };
116
+ } finally {
117
+ await rm(tempDirectoryPath, { force: true, recursive: true });
118
+ }
119
+ }
120
+
121
+ export async function runOxlintJson(options: IRunOxlintJsonOptions): Promise<readonly IOxlintDiagnostic[]> {
122
+ const runResult = await runOxlintProcess(options);
123
+ const stdout = runResult.stdout.trim();
124
+ if (stdout.length === 0) {
125
+ throw new Error(`Oxlint returned no JSON output.\n\nStderr:\n${runResult.stderr}`);
126
+ }
127
+
128
+ const parsedOutput: unknown = JSON.parse(stdout);
129
+ const diagnostics = readDiagnostics(parsedOutput);
130
+
131
+ if (runResult.exitCode !== 0 && runResult.exitCode !== 1) {
132
+ const signalSuffix = runResult.signal ? `, signal=${runResult.signal}` : "";
133
+ throw new Error(
134
+ `Oxlint failed with exit code ${String(runResult.exitCode)}${signalSuffix}.\n\nStderr:\n${runResult.stderr}`,
135
+ );
136
+ }
137
+
138
+ return diagnostics;
139
+ }