@osovv/grace-cli 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -4
- package/package.json +4 -2
- package/src/grace-lint.ts +30 -732
- package/src/grace.ts +1 -1
- package/src/lint/adapters/base.ts +11 -0
- package/src/lint/adapters/typescript.ts +185 -0
- package/src/lint/config.ts +51 -0
- package/src/lint/core.ts +961 -0
- package/src/lint/types.ts +75 -0
package/src/grace.ts
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { LanguageAdapter } from "../types";
|
|
4
|
+
import { createTypeScriptAdapter } from "./typescript";
|
|
5
|
+
|
|
6
|
+
const adapters: LanguageAdapter[] = [createTypeScriptAdapter()];
|
|
7
|
+
|
|
8
|
+
export function getLanguageAdapter(filePath: string) {
|
|
9
|
+
const normalizedPath = path.normalize(filePath);
|
|
10
|
+
return adapters.find((adapter) => adapter.supports(normalizedPath)) ?? null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
import type { LanguageAdapter, LanguageAnalysis } from "../types";
|
|
5
|
+
|
|
6
|
+
const TS_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
|
|
7
|
+
const TEST_IMPORTS = new Set(["bun:test", "vitest", "jest", "@jest/globals", "node:test"]);
|
|
8
|
+
const TEST_CALLS = new Set(["describe", "it", "test", "beforeEach", "afterEach", "beforeAll", "afterAll", "suite"]);
|
|
9
|
+
|
|
10
|
+
function getScriptKind(filePath: string) {
|
|
11
|
+
const ext = path.extname(filePath);
|
|
12
|
+
switch (ext) {
|
|
13
|
+
case ".js":
|
|
14
|
+
case ".mjs":
|
|
15
|
+
case ".cjs":
|
|
16
|
+
return ts.ScriptKind.JS;
|
|
17
|
+
case ".jsx":
|
|
18
|
+
return ts.ScriptKind.JSX;
|
|
19
|
+
case ".tsx":
|
|
20
|
+
return ts.ScriptKind.TSX;
|
|
21
|
+
default:
|
|
22
|
+
return ts.ScriptKind.TS;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasExportModifier(node: ts.Node) {
|
|
27
|
+
return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasDefaultModifier(node: ts.Node) {
|
|
31
|
+
return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function addExport(
|
|
35
|
+
analysis: LanguageAnalysis,
|
|
36
|
+
name: string,
|
|
37
|
+
kind: "value" | "type",
|
|
38
|
+
options: { local?: boolean; defaultExport?: boolean } = {},
|
|
39
|
+
) {
|
|
40
|
+
analysis.exports.add(name);
|
|
41
|
+
if (kind === "type") {
|
|
42
|
+
analysis.typeExports.add(name);
|
|
43
|
+
} else {
|
|
44
|
+
analysis.valueExports.add(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.local) {
|
|
48
|
+
analysis.localExportCount += 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (options.defaultExport) {
|
|
52
|
+
analysis.hasDefaultExport = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createTypeScriptAdapter(): LanguageAdapter {
|
|
57
|
+
return {
|
|
58
|
+
id: "js-ts",
|
|
59
|
+
supports(filePath) {
|
|
60
|
+
return TS_EXTENSIONS.has(path.extname(filePath));
|
|
61
|
+
},
|
|
62
|
+
analyze(filePath, text) {
|
|
63
|
+
const sourceFile = ts.createSourceFile(
|
|
64
|
+
filePath,
|
|
65
|
+
text,
|
|
66
|
+
ts.ScriptTarget.Latest,
|
|
67
|
+
true,
|
|
68
|
+
getScriptKind(filePath),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const analysis: LanguageAnalysis = {
|
|
72
|
+
adapterId: "js-ts",
|
|
73
|
+
exports: new Set<string>(),
|
|
74
|
+
valueExports: new Set<string>(),
|
|
75
|
+
typeExports: new Set<string>(),
|
|
76
|
+
hasDefaultExport: false,
|
|
77
|
+
hasWildcardReExport: false,
|
|
78
|
+
directReExportCount: 0,
|
|
79
|
+
localExportCount: 0,
|
|
80
|
+
localImplementationCount: 0,
|
|
81
|
+
usesTestFramework: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
for (const statement of sourceFile.statements) {
|
|
85
|
+
if (ts.isImportDeclaration(statement)) {
|
|
86
|
+
const importSource = ts.isStringLiteral(statement.moduleSpecifier)
|
|
87
|
+
? statement.moduleSpecifier.text
|
|
88
|
+
: null;
|
|
89
|
+
if (importSource && TEST_IMPORTS.has(importSource)) {
|
|
90
|
+
analysis.usesTestFramework = true;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (ts.isExpressionStatement(statement)) {
|
|
96
|
+
const expression = statement.expression;
|
|
97
|
+
if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && TEST_CALLS.has(expression.expression.text)) {
|
|
98
|
+
analysis.usesTestFramework = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ts.isExportAssignment(statement)) {
|
|
103
|
+
analysis.localImplementationCount += 1;
|
|
104
|
+
addExport(analysis, "default", "value", { local: true, defaultExport: true });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (ts.isExportDeclaration(statement)) {
|
|
109
|
+
const isReExport = Boolean(statement.moduleSpecifier);
|
|
110
|
+
if (isReExport) {
|
|
111
|
+
analysis.directReExportCount += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!statement.exportClause) {
|
|
115
|
+
analysis.hasWildcardReExport = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ts.isNamedExports(statement.exportClause)) {
|
|
120
|
+
for (const element of statement.exportClause.elements) {
|
|
121
|
+
const exportName = element.name.text;
|
|
122
|
+
const isTypeOnly = statement.isTypeOnly || element.isTypeOnly;
|
|
123
|
+
addExport(analysis, exportName, isTypeOnly ? "type" : "value", isReExport ? {} : { local: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (ts.isVariableStatement(statement) && hasExportModifier(statement)) {
|
|
130
|
+
analysis.localImplementationCount += 1;
|
|
131
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
132
|
+
if (ts.isIdentifier(declaration.name)) {
|
|
133
|
+
addExport(analysis, declaration.name.text, "value", { local: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ts.isFunctionDeclaration(statement) && hasExportModifier(statement)) {
|
|
140
|
+
analysis.localImplementationCount += 1;
|
|
141
|
+
if (hasDefaultModifier(statement)) {
|
|
142
|
+
addExport(analysis, "default", "value", { local: true, defaultExport: true });
|
|
143
|
+
} else if (statement.name) {
|
|
144
|
+
addExport(analysis, statement.name.text, "value", { local: true });
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (ts.isClassDeclaration(statement) && hasExportModifier(statement)) {
|
|
150
|
+
analysis.localImplementationCount += 1;
|
|
151
|
+
if (hasDefaultModifier(statement)) {
|
|
152
|
+
addExport(analysis, "default", "value", { local: true, defaultExport: true });
|
|
153
|
+
} else if (statement.name) {
|
|
154
|
+
addExport(analysis, statement.name.text, "value", { local: true });
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (ts.isInterfaceDeclaration(statement) && hasExportModifier(statement)) {
|
|
160
|
+
addExport(analysis, statement.name.text, "type", { local: true });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (ts.isTypeAliasDeclaration(statement) && hasExportModifier(statement)) {
|
|
165
|
+
addExport(analysis, statement.name.text, "type", { local: true });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (ts.isEnumDeclaration(statement) && hasExportModifier(statement)) {
|
|
170
|
+
analysis.localImplementationCount += 1;
|
|
171
|
+
addExport(analysis, statement.name.text, "value", { local: true });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (ts.isModuleDeclaration(statement) && hasExportModifier(statement)) {
|
|
176
|
+
analysis.localImplementationCount += 1;
|
|
177
|
+
addExport(analysis, statement.name.getText(sourceFile), "value", { local: true });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return analysis;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { GraceLintConfig, LintIssue } from "./types";
|
|
5
|
+
|
|
6
|
+
const CONFIG_FILE_NAME = ".grace-lint.json";
|
|
7
|
+
const VALID_PROFILES = new Set(["auto", "current", "legacy"]);
|
|
8
|
+
|
|
9
|
+
export function loadGraceLintConfig(projectRoot: string) {
|
|
10
|
+
const configPath = path.join(projectRoot, CONFIG_FILE_NAME);
|
|
11
|
+
if (!existsSync(configPath)) {
|
|
12
|
+
return { config: null as GraceLintConfig | null, issues: [] as LintIssue[] };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as GraceLintConfig;
|
|
17
|
+
const issues: LintIssue[] = [];
|
|
18
|
+
|
|
19
|
+
if (parsed.profile && !VALID_PROFILES.has(parsed.profile)) {
|
|
20
|
+
issues.push({
|
|
21
|
+
severity: "error",
|
|
22
|
+
code: "config.invalid-profile",
|
|
23
|
+
file: CONFIG_FILE_NAME,
|
|
24
|
+
message: `Unsupported profile \`${parsed.profile}\` in ${CONFIG_FILE_NAME}. Use \`auto\`, \`current\`, or \`legacy\`.`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (parsed.ignoredDirs && !Array.isArray(parsed.ignoredDirs)) {
|
|
29
|
+
issues.push({
|
|
30
|
+
severity: "error",
|
|
31
|
+
code: "config.invalid-ignored-dirs",
|
|
32
|
+
file: CONFIG_FILE_NAME,
|
|
33
|
+
message: `\`ignoredDirs\` in ${CONFIG_FILE_NAME} must be an array of directory names.`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { config: parsed, issues };
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
config: null,
|
|
41
|
+
issues: [
|
|
42
|
+
{
|
|
43
|
+
severity: "error",
|
|
44
|
+
code: "config.invalid-json",
|
|
45
|
+
file: CONFIG_FILE_NAME,
|
|
46
|
+
message: `Failed to parse ${CONFIG_FILE_NAME}: ${error instanceof Error ? error.message : String(error)}`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|