@osovv/grace-cli 3.1.0 → 3.3.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/src/grace.ts CHANGED
@@ -7,7 +7,7 @@ import { lintCommand } from "./grace-lint";
7
7
  const main = defineCommand({
8
8
  meta: {
9
9
  name: "grace",
10
- version: "3.1.0",
10
+ version: "3.3.0",
11
11
  description: "GRACE CLI for linting semantic markup and GRACE project artifacts.",
12
12
  },
13
13
  subCommands: {
@@ -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,65 @@
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 SUPPORTED_KEYS = new Set(["ignoredDirs"]);
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 || Array.isArray(parsed) || typeof parsed !== "object") {
20
+ issues.push({
21
+ severity: "error",
22
+ code: "config.invalid-shape",
23
+ file: CONFIG_FILE_NAME,
24
+ message: `${CONFIG_FILE_NAME} must contain a JSON object.`,
25
+ });
26
+ return { config: parsed, issues };
27
+ }
28
+
29
+ for (const key of Object.keys(parsed)) {
30
+ if (SUPPORTED_KEYS.has(key)) {
31
+ continue;
32
+ }
33
+
34
+ issues.push({
35
+ severity: "error",
36
+ code: "config.unknown-key",
37
+ file: CONFIG_FILE_NAME,
38
+ message: `Unsupported key \`${key}\` in ${CONFIG_FILE_NAME}. Supported keys: ignoredDirs.`,
39
+ });
40
+ }
41
+
42
+ if (parsed.ignoredDirs && !Array.isArray(parsed.ignoredDirs)) {
43
+ issues.push({
44
+ severity: "error",
45
+ code: "config.invalid-ignored-dirs",
46
+ file: CONFIG_FILE_NAME,
47
+ message: `\`ignoredDirs\` in ${CONFIG_FILE_NAME} must be an array of directory names.`,
48
+ });
49
+ }
50
+
51
+ return { config: parsed, issues };
52
+ } catch (error) {
53
+ return {
54
+ config: null,
55
+ issues: [
56
+ {
57
+ severity: "error",
58
+ code: "config.invalid-json",
59
+ file: CONFIG_FILE_NAME,
60
+ message: `Failed to parse ${CONFIG_FILE_NAME}: ${error instanceof Error ? error.message : String(error)}`,
61
+ },
62
+ ],
63
+ };
64
+ }
65
+ }