ai-quality-gate 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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/dist/eslint/config.mjs +271 -0
- package/dist/eslint/rules.json +243 -0
- package/dist/eslint/sonarjs-rules.js +28 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +3007 -0
- package/dist/server.js.map +1 -0
- package/package.json +88 -0
- package/src/eslint/config.mjs +271 -0
- package/src/eslint/embedded-v21-rules.integration.test.ts +72 -0
- package/src/eslint/rules-contract.test.ts +27 -0
- package/src/eslint/rules.json +243 -0
- package/src/eslint/sonarjs-rules.js +28 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,3007 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import * as z2 from 'zod/v4';
|
|
5
|
+
import { parseArgs } from 'util';
|
|
6
|
+
import path13 from 'path';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import * as fs8 from 'fs';
|
|
9
|
+
import fs8__default from 'fs';
|
|
10
|
+
import { parse, stringify } from 'yaml';
|
|
11
|
+
import { SyntaxKind, Project } from 'ts-morph';
|
|
12
|
+
import * as fs4 from 'fs/promises';
|
|
13
|
+
import { readFile } from 'fs/promises';
|
|
14
|
+
import { spawn } from 'child_process';
|
|
15
|
+
import { setTimeout } from 'timers/promises';
|
|
16
|
+
import axios from 'axios';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import inquirer from 'inquirer';
|
|
19
|
+
|
|
20
|
+
var CLI_OPTION = {
|
|
21
|
+
CHECK: "check",
|
|
22
|
+
FIX: "fix",
|
|
23
|
+
PHASE1_ONLY: "phase1-only",
|
|
24
|
+
PHASE2_ONLY: "phase2-only",
|
|
25
|
+
CONFIG: "config",
|
|
26
|
+
HELP: "help",
|
|
27
|
+
SETUP: "setup",
|
|
28
|
+
VERSION: "version"
|
|
29
|
+
};
|
|
30
|
+
var CLI_ENTRY_FLAGS = /* @__PURE__ */ new Set([
|
|
31
|
+
"-h",
|
|
32
|
+
`--${CLI_OPTION.HELP}`,
|
|
33
|
+
`--${CLI_OPTION.VERSION}`,
|
|
34
|
+
`--${CLI_OPTION.SETUP}`,
|
|
35
|
+
`--${CLI_OPTION.CHECK}`,
|
|
36
|
+
`--${CLI_OPTION.FIX}`,
|
|
37
|
+
`--${CLI_OPTION.PHASE1_ONLY}`,
|
|
38
|
+
`--${CLI_OPTION.PHASE2_ONLY}`,
|
|
39
|
+
`--${CLI_OPTION.CONFIG}`
|
|
40
|
+
]);
|
|
41
|
+
function shouldUseCliMode(argv) {
|
|
42
|
+
for (const arg of argv.slice(2)) {
|
|
43
|
+
if (CLI_ENTRY_FLAGS.has(arg)) return true;
|
|
44
|
+
if (arg.startsWith("--config=")) return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function buildQualityGateOptions(values) {
|
|
49
|
+
const { check, fix, phase1Only, phase2Only } = values;
|
|
50
|
+
if (phase1Only && phase2Only) return null;
|
|
51
|
+
if (phase2Only) {
|
|
52
|
+
return { phases: "phase2" };
|
|
53
|
+
}
|
|
54
|
+
if (check && fix) return null;
|
|
55
|
+
if (!check && !fix) return null;
|
|
56
|
+
const phase1Mode = check ? "check" : "fix";
|
|
57
|
+
if (phase1Only) {
|
|
58
|
+
return { phase1Mode, phases: "phase1" };
|
|
59
|
+
}
|
|
60
|
+
return { phase1Mode, phases: "all" };
|
|
61
|
+
}
|
|
62
|
+
function readCliParseArgs(args) {
|
|
63
|
+
const { positionals, values } = parseArgs({
|
|
64
|
+
args,
|
|
65
|
+
options: {
|
|
66
|
+
[CLI_OPTION.CHECK]: { type: "boolean" },
|
|
67
|
+
[CLI_OPTION.FIX]: { type: "boolean" },
|
|
68
|
+
[CLI_OPTION.PHASE1_ONLY]: { type: "boolean" },
|
|
69
|
+
[CLI_OPTION.PHASE2_ONLY]: { type: "boolean" },
|
|
70
|
+
[CLI_OPTION.CONFIG]: { type: "string" },
|
|
71
|
+
[CLI_OPTION.HELP]: { type: "boolean", short: "h" },
|
|
72
|
+
[CLI_OPTION.SETUP]: { type: "boolean" },
|
|
73
|
+
[CLI_OPTION.VERSION]: { type: "boolean" }
|
|
74
|
+
},
|
|
75
|
+
allowPositionals: true,
|
|
76
|
+
strict: true
|
|
77
|
+
});
|
|
78
|
+
return { positionals, values };
|
|
79
|
+
}
|
|
80
|
+
function parseCliRunOrError(values, positionals) {
|
|
81
|
+
const configRaw = values[CLI_OPTION.CONFIG];
|
|
82
|
+
const configPath = typeof configRaw === "string" && configRaw.length > 0 ? configRaw : void 0;
|
|
83
|
+
const gateOpts = buildQualityGateOptions({
|
|
84
|
+
check: values[CLI_OPTION.CHECK] === true,
|
|
85
|
+
fix: values[CLI_OPTION.FIX] === true,
|
|
86
|
+
phase1Only: values[CLI_OPTION.PHASE1_ONLY] === true,
|
|
87
|
+
phase2Only: values[CLI_OPTION.PHASE2_ONLY] === true
|
|
88
|
+
});
|
|
89
|
+
if (gateOpts === null) {
|
|
90
|
+
return {
|
|
91
|
+
error: "Invalid flags: use --check or --fix when Phase 1 runs; do not combine --check with --fix; do not combine --phase1-only with --phase2-only.",
|
|
92
|
+
ok: false
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (positionals.length === 0) {
|
|
96
|
+
return { error: "Specify at least one file path.", ok: false };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: "run",
|
|
100
|
+
ok: true,
|
|
101
|
+
payload: {
|
|
102
|
+
configPath,
|
|
103
|
+
files: positionals,
|
|
104
|
+
qualityGateOptions: gateOpts
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseSetupCliResult(values, positionals) {
|
|
109
|
+
if (values[CLI_OPTION.SETUP] !== true) return null;
|
|
110
|
+
const conflicting = values[CLI_OPTION.CHECK] === true || values[CLI_OPTION.FIX] === true || values[CLI_OPTION.PHASE1_ONLY] === true || values[CLI_OPTION.PHASE2_ONLY] === true;
|
|
111
|
+
if (conflicting) {
|
|
112
|
+
return {
|
|
113
|
+
error: "Do not combine --setup with --check, --fix, --phase1-only, or --phase2-only.",
|
|
114
|
+
ok: false
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (positionals.length > 0) {
|
|
118
|
+
return { error: "Do not pass file paths with --setup.", ok: false };
|
|
119
|
+
}
|
|
120
|
+
return { kind: "setup", ok: true };
|
|
121
|
+
}
|
|
122
|
+
function parseCliArgs(argv) {
|
|
123
|
+
const args = argv.slice(2);
|
|
124
|
+
if (args.length === 0) {
|
|
125
|
+
return { error: "CLI parser invoked with no arguments.", ok: false };
|
|
126
|
+
}
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = readCliParseArgs(args);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
132
|
+
return { error: message, ok: false };
|
|
133
|
+
}
|
|
134
|
+
const { positionals, values } = parsed;
|
|
135
|
+
if (values[CLI_OPTION.HELP] === true) {
|
|
136
|
+
return { kind: "help", ok: true };
|
|
137
|
+
}
|
|
138
|
+
if (values[CLI_OPTION.VERSION] === true) {
|
|
139
|
+
return { kind: "version", ok: true };
|
|
140
|
+
}
|
|
141
|
+
const setupResult = parseSetupCliResult(values, positionals);
|
|
142
|
+
if (setupResult !== null) return setupResult;
|
|
143
|
+
return parseCliRunOrError(values, positionals);
|
|
144
|
+
}
|
|
145
|
+
var ENV_KEYS = {
|
|
146
|
+
/** Optional absolute or relative path to `.quality-gate.yaml` / `.quality-gate.json` (skips discovery) */
|
|
147
|
+
QUALITY_GATE_CONFIG: "QUALITY_GATE_CONFIG",
|
|
148
|
+
/** Optional override; when unset, project root is inferred (walk up from cwd for `package.json` / `tsconfig.json`) */
|
|
149
|
+
PROJECT_ROOT: "PROJECT_ROOT",
|
|
150
|
+
SONAR_HOST_URL: "SONAR_HOST_URL",
|
|
151
|
+
SONAR_TOKEN: "SONAR_TOKEN",
|
|
152
|
+
SONAR_PROJECT_KEY: "SONAR_PROJECT_KEY",
|
|
153
|
+
SONAR_SCANNER_PATH: "SONAR_SCANNER_PATH",
|
|
154
|
+
PHASE1_TIMEOUT: "PHASE1_TIMEOUT",
|
|
155
|
+
PHASE2_TIMEOUT: "PHASE2_TIMEOUT",
|
|
156
|
+
// Feature flags
|
|
157
|
+
ENABLE_I18N_RULES: "ENABLE_I18N_RULES"
|
|
158
|
+
};
|
|
159
|
+
var CONFIG_DEFAULTS = {
|
|
160
|
+
/** Phase 1 timeout (TypeScript + ESLint) - 30 seconds */
|
|
161
|
+
PHASE1_TIMEOUT: 3e4,
|
|
162
|
+
/** Phase 2 timeout (SonarQube) - 5 minutes for large monorepos */
|
|
163
|
+
PHASE2_TIMEOUT: 3e5
|
|
164
|
+
};
|
|
165
|
+
var FixerConfigPartialSchema = z.object({
|
|
166
|
+
curlyBraces: z.boolean().optional(),
|
|
167
|
+
eslint: z.boolean().optional(),
|
|
168
|
+
jsonValidator: z.boolean().optional(),
|
|
169
|
+
prettier: z.boolean().optional(),
|
|
170
|
+
singleLineArrow: z.boolean().optional()
|
|
171
|
+
});
|
|
172
|
+
var FixerConfigSchema = FixerConfigPartialSchema.default({}).transform((p) => ({
|
|
173
|
+
curlyBraces: p.curlyBraces ?? true,
|
|
174
|
+
eslint: p.eslint ?? true,
|
|
175
|
+
jsonValidator: p.jsonValidator ?? true,
|
|
176
|
+
prettier: p.prettier ?? true,
|
|
177
|
+
singleLineArrow: p.singleLineArrow ?? true
|
|
178
|
+
}));
|
|
179
|
+
var CustomRuleSchema = z.object({
|
|
180
|
+
id: z.string().min(1),
|
|
181
|
+
message: z.string().min(1),
|
|
182
|
+
pattern: z.string().min(1),
|
|
183
|
+
severity: z.enum(["error", "info", "warning"])
|
|
184
|
+
});
|
|
185
|
+
var ConfigSchema = z.object({
|
|
186
|
+
/** Set by merge + `ConfigManager.ensureProjectRoot` before parse (file, env, or `findProjectRoot`) */
|
|
187
|
+
projectRoot: z.string().min(1),
|
|
188
|
+
// Optional (Phase 2 - SonarQube Server)
|
|
189
|
+
sonarHostUrl: z.url().optional(),
|
|
190
|
+
sonarToken: z.string().min(1).optional(),
|
|
191
|
+
sonarProjectKey: z.string().min(1).optional(),
|
|
192
|
+
sonarScannerPath: z.string().optional(),
|
|
193
|
+
// Timeouts with defaults
|
|
194
|
+
phase1Timeout: z.number().positive().default(CONFIG_DEFAULTS.PHASE1_TIMEOUT),
|
|
195
|
+
phase2Timeout: z.number().positive().default(CONFIG_DEFAULTS.PHASE2_TIMEOUT),
|
|
196
|
+
// Feature flags
|
|
197
|
+
/** Enable i18n rules (no-literal-string) - default false for non-i18n projects */
|
|
198
|
+
enableI18nRules: z.boolean().default(false),
|
|
199
|
+
/** Phase 1 tool toggles (defaults: all enabled) */
|
|
200
|
+
fixers: FixerConfigSchema,
|
|
201
|
+
/** Optional regex-based rules (rule id appears as `custom:<id>` in issues) */
|
|
202
|
+
customRules: z.array(CustomRuleSchema).optional()
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// src/constants/project-root.ts
|
|
206
|
+
var PROJECT_ROOT_MARKER_FILES = ["package.json", "tsconfig.json"];
|
|
207
|
+
|
|
208
|
+
// src/utils/findProjectRoot.ts
|
|
209
|
+
function hasProjectMarker(dir) {
|
|
210
|
+
for (const name of PROJECT_ROOT_MARKER_FILES) {
|
|
211
|
+
if (fs8__default.existsSync(path13.join(dir, name))) return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
function findProjectRoot(startDir) {
|
|
216
|
+
let current = path13.resolve(startDir);
|
|
217
|
+
while (true) {
|
|
218
|
+
if (hasProjectMarker(current)) return current;
|
|
219
|
+
const parent = path13.dirname(current);
|
|
220
|
+
if (parent === current) break;
|
|
221
|
+
current = parent;
|
|
222
|
+
}
|
|
223
|
+
return path13.resolve(startDir);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/constants/config-files.ts
|
|
227
|
+
var CONFIG_FILE_NAMES = {
|
|
228
|
+
YAML: ".quality-gate.yaml",
|
|
229
|
+
JSON: ".quality-gate.json"
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/config/configFile.ts
|
|
233
|
+
var discoverConfigFilePath = (startDir) => {
|
|
234
|
+
let current = path13.resolve(startDir);
|
|
235
|
+
while (true) {
|
|
236
|
+
const yamlPath = path13.join(current, CONFIG_FILE_NAMES.YAML);
|
|
237
|
+
if (fs8__default.existsSync(yamlPath)) return yamlPath;
|
|
238
|
+
const jsonPath = path13.join(current, CONFIG_FILE_NAMES.JSON);
|
|
239
|
+
if (fs8__default.existsSync(jsonPath)) return jsonPath;
|
|
240
|
+
const parent = path13.dirname(current);
|
|
241
|
+
if (parent === current) break;
|
|
242
|
+
current = parent;
|
|
243
|
+
}
|
|
244
|
+
return void 0;
|
|
245
|
+
};
|
|
246
|
+
var resolveExplicitConfigPath = (explicitPath) => {
|
|
247
|
+
const resolved = path13.resolve(explicitPath);
|
|
248
|
+
if (!fs8__default.existsSync(resolved)) throw new Error(`Config file not found: ${resolved}`);
|
|
249
|
+
return resolved;
|
|
250
|
+
};
|
|
251
|
+
var loadConfigFile = (absolutePath) => {
|
|
252
|
+
const text = fs8__default.readFileSync(absolutePath, "utf8");
|
|
253
|
+
const ext = path13.extname(absolutePath).toLowerCase();
|
|
254
|
+
if (ext === ".json") {
|
|
255
|
+
const parsed2 = JSON.parse(text);
|
|
256
|
+
return parsed2 !== null && typeof parsed2 === "object" && !Array.isArray(parsed2) ? parsed2 : {};
|
|
257
|
+
}
|
|
258
|
+
const parsed = parse(text);
|
|
259
|
+
if (parsed === null || parsed === void 0) return {};
|
|
260
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
261
|
+
throw new TypeError(`Invalid YAML config (expected object): ${absolutePath}`);
|
|
262
|
+
}
|
|
263
|
+
return parsed;
|
|
264
|
+
};
|
|
265
|
+
var normalizeRawConfig = (raw) => {
|
|
266
|
+
const out = { ...raw };
|
|
267
|
+
const sonar = out["sonar"];
|
|
268
|
+
if (sonar !== null && typeof sonar === "object" && !Array.isArray(sonar)) {
|
|
269
|
+
const s = sonar;
|
|
270
|
+
if (s.hostUrl !== void 0) out["sonarHostUrl"] = s.hostUrl;
|
|
271
|
+
if (s.token !== void 0) out["sonarToken"] = s.token;
|
|
272
|
+
if (s.projectKey !== void 0) out["sonarProjectKey"] = s.projectKey;
|
|
273
|
+
if (s.scannerPath !== void 0) out["sonarScannerPath"] = s.scannerPath;
|
|
274
|
+
Reflect.deleteProperty(out, "sonar");
|
|
275
|
+
}
|
|
276
|
+
coerceConfigFields(out);
|
|
277
|
+
return out;
|
|
278
|
+
};
|
|
279
|
+
var coerceConfigFields = (raw) => {
|
|
280
|
+
const timeoutKeys = ["phase1Timeout", "phase2Timeout"];
|
|
281
|
+
for (const k of timeoutKeys) {
|
|
282
|
+
const v = raw[k];
|
|
283
|
+
if (typeof v === "string" && /^\d+$/.test(v.trim())) raw[k] = Number.parseInt(v.trim(), 10);
|
|
284
|
+
}
|
|
285
|
+
const e = raw["enableI18nRules"];
|
|
286
|
+
if (typeof e === "string") {
|
|
287
|
+
const lower = e.toLowerCase();
|
|
288
|
+
if (lower === "true") raw["enableI18nRules"] = true;
|
|
289
|
+
else if (lower === "false") raw["enableI18nRules"] = false;
|
|
290
|
+
}
|
|
291
|
+
coerceFixersBooleanStrings(raw);
|
|
292
|
+
};
|
|
293
|
+
var coerceFixersBooleanStrings = (raw) => {
|
|
294
|
+
const fixersRaw = raw["fixers"];
|
|
295
|
+
if (fixersRaw === null || typeof fixersRaw !== "object" || Array.isArray(fixersRaw)) return;
|
|
296
|
+
const fx = fixersRaw;
|
|
297
|
+
const keys = ["eslint", "curlyBraces", "singleLineArrow", "prettier", "jsonValidator"];
|
|
298
|
+
for (const k of keys) {
|
|
299
|
+
const v = fx[k];
|
|
300
|
+
if (typeof v === "string") {
|
|
301
|
+
const lower = v.toLowerCase();
|
|
302
|
+
if (lower === "true") fx[k] = true;
|
|
303
|
+
else if (lower === "false") fx[k] = false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
var resolveProjectRootAgainstConfigFile = (raw, configFilePath) => {
|
|
308
|
+
const pr = raw["projectRoot"];
|
|
309
|
+
if (typeof pr !== "string" || pr.length === 0) return;
|
|
310
|
+
if (path13.isAbsolute(pr)) return;
|
|
311
|
+
const configDir = path13.dirname(configFilePath);
|
|
312
|
+
raw["projectRoot"] = path13.resolve(configDir, pr);
|
|
313
|
+
};
|
|
314
|
+
var mergeConfigFileWithEnv = (filePartial, envPartial) => {
|
|
315
|
+
const merged = { ...filePartial };
|
|
316
|
+
for (const [key, value] of Object.entries(envPartial)) {
|
|
317
|
+
if (value !== void 0) merged[key] = value;
|
|
318
|
+
}
|
|
319
|
+
return merged;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// src/config/ConfigManager.ts
|
|
323
|
+
function formatConfigInvalidMessage(issues) {
|
|
324
|
+
const parts = issues.map((issue) => {
|
|
325
|
+
const pathStr = issue.path.length === 0 ? "(root)" : issue.path.map(String).join(".");
|
|
326
|
+
return `'${pathStr}' ${issue.message}`;
|
|
327
|
+
});
|
|
328
|
+
return `Config: ${parts.join("; ")}`;
|
|
329
|
+
}
|
|
330
|
+
var ConfigManager = class {
|
|
331
|
+
config = null;
|
|
332
|
+
/**
|
|
333
|
+
* Load and validate configuration (optional YAML/JSON file + environment variables)
|
|
334
|
+
* @throws Error if validation fails
|
|
335
|
+
*/
|
|
336
|
+
load() {
|
|
337
|
+
if (this.config !== null) return this.config;
|
|
338
|
+
const merged = this.loadMergedRaw();
|
|
339
|
+
const result = ConfigSchema.safeParse(merged);
|
|
340
|
+
if (!result.success) throw new Error(formatConfigInvalidMessage(result.error.issues));
|
|
341
|
+
this.config = result.data;
|
|
342
|
+
return this.config;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Merge config file (if any) with ENV; file first, then ENV overwrites defined keys.
|
|
346
|
+
*/
|
|
347
|
+
loadMergedRaw() {
|
|
348
|
+
const envRaw = this.readFromEnv();
|
|
349
|
+
const explicit = process.env[ENV_KEYS.QUALITY_GATE_CONFIG]?.trim();
|
|
350
|
+
const projectRootEnv = process.env[ENV_KEYS.PROJECT_ROOT]?.trim();
|
|
351
|
+
const configDiscoveryStartDir = projectRootEnv ? path13.resolve(projectRootEnv) : findProjectRoot(process.cwd());
|
|
352
|
+
let fileRaw = {};
|
|
353
|
+
if (explicit && explicit.length > 0) {
|
|
354
|
+
const configPath = resolveExplicitConfigPath(explicit);
|
|
355
|
+
fileRaw = loadConfigFile(configPath);
|
|
356
|
+
fileRaw = normalizeRawConfig(fileRaw);
|
|
357
|
+
resolveProjectRootAgainstConfigFile(fileRaw, configPath);
|
|
358
|
+
} else {
|
|
359
|
+
const discovered = discoverConfigFilePath(configDiscoveryStartDir);
|
|
360
|
+
if (discovered) {
|
|
361
|
+
fileRaw = loadConfigFile(discovered);
|
|
362
|
+
fileRaw = normalizeRawConfig(fileRaw);
|
|
363
|
+
resolveProjectRootAgainstConfigFile(fileRaw, discovered);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const merged = mergeConfigFileWithEnv(fileRaw, envRaw);
|
|
367
|
+
this.ensureProjectRoot(merged);
|
|
368
|
+
return merged;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Ensure `projectRoot` is set: env `PROJECT_ROOT`, then config merge, else walk up from cwd.
|
|
372
|
+
*/
|
|
373
|
+
ensureProjectRoot(merged) {
|
|
374
|
+
const m = merged;
|
|
375
|
+
const existing = m.projectRoot;
|
|
376
|
+
if (typeof existing === "string" && existing.trim().length > 0) return;
|
|
377
|
+
const fromEnv = process.env[ENV_KEYS.PROJECT_ROOT]?.trim();
|
|
378
|
+
m.projectRoot = fromEnv && fromEnv.length > 0 ? path13.resolve(fromEnv) : findProjectRoot(process.cwd());
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Check if Phase 2 (SonarQube Server) is configured
|
|
382
|
+
*/
|
|
383
|
+
isPhase2Configured() {
|
|
384
|
+
const config = this.load();
|
|
385
|
+
return Boolean(config.sonarHostUrl && config.sonarToken && config.sonarProjectKey);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Reset cached config (useful for testing)
|
|
389
|
+
*/
|
|
390
|
+
reset() {
|
|
391
|
+
this.config = null;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Read raw values from environment variables
|
|
395
|
+
*/
|
|
396
|
+
readFromEnv() {
|
|
397
|
+
const { env } = process;
|
|
398
|
+
return {
|
|
399
|
+
projectRoot: env[ENV_KEYS.PROJECT_ROOT],
|
|
400
|
+
sonarHostUrl: env[ENV_KEYS.SONAR_HOST_URL],
|
|
401
|
+
sonarToken: env[ENV_KEYS.SONAR_TOKEN],
|
|
402
|
+
sonarProjectKey: env[ENV_KEYS.SONAR_PROJECT_KEY],
|
|
403
|
+
sonarScannerPath: env[ENV_KEYS.SONAR_SCANNER_PATH],
|
|
404
|
+
phase1Timeout: this.parseNumber(env[ENV_KEYS.PHASE1_TIMEOUT]),
|
|
405
|
+
phase2Timeout: this.parseNumber(env[ENV_KEYS.PHASE2_TIMEOUT]),
|
|
406
|
+
enableI18nRules: this.parseBoolean(env[ENV_KEYS.ENABLE_I18N_RULES])
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Parse string to boolean, return undefined if invalid
|
|
411
|
+
*/
|
|
412
|
+
parseBoolean(value) {
|
|
413
|
+
if (value === void 0) return void 0;
|
|
414
|
+
return value.toLowerCase() === "true";
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Parse string to number, return undefined if invalid
|
|
418
|
+
*/
|
|
419
|
+
parseNumber(value) {
|
|
420
|
+
if (value === void 0) return void 0;
|
|
421
|
+
const num = Number.parseInt(value, 10);
|
|
422
|
+
return Number.isNaN(num) ? void 0 : num;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
var configManager = new ConfigManager();
|
|
426
|
+
|
|
427
|
+
// src/constants/severity.ts
|
|
428
|
+
var SEVERITY = {
|
|
429
|
+
ERROR: "error",
|
|
430
|
+
WARNING: "warning",
|
|
431
|
+
INFO: "info"
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// src/constants/phases.ts
|
|
435
|
+
var PHASE = {
|
|
436
|
+
/** Local analysis - TypeScript + ESLint + AST fixers (~2-3s) */
|
|
437
|
+
LOCAL: "local",
|
|
438
|
+
/** Server analysis - SonarQube deep scan (~30-60s) */
|
|
439
|
+
SERVER: "server",
|
|
440
|
+
/** All phases completed successfully */
|
|
441
|
+
COMPLETE: "complete"
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// src/constants/fixers.ts
|
|
445
|
+
var FIXER_TYPE = {
|
|
446
|
+
/** Remove unnecessary curly braces from single-statement if blocks */
|
|
447
|
+
CURLY_BRACES: "curlyBraces",
|
|
448
|
+
/** Convert multi-line single-statement arrow functions to single line */
|
|
449
|
+
SINGLE_LINE_ARROW: "singleLineArrow"
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// src/constants/sonarqube.ts
|
|
453
|
+
var SONAR_SEVERITY = {
|
|
454
|
+
BLOCKER: "BLOCKER",
|
|
455
|
+
CRITICAL: "CRITICAL",
|
|
456
|
+
MAJOR: "MAJOR"};
|
|
457
|
+
var SONAR_TASK_STATUS = {
|
|
458
|
+
PENDING: "PENDING",
|
|
459
|
+
IN_PROGRESS: "IN_PROGRESS",
|
|
460
|
+
SUCCESS: "SUCCESS",
|
|
461
|
+
FAILED: "FAILED",
|
|
462
|
+
CANCELED: "CANCELED"
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// src/constants/extensions.ts
|
|
466
|
+
var SUPPORTED_CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
467
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
468
|
+
// JavaScript/TypeScript (Primary Focus)
|
|
469
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
470
|
+
".ts",
|
|
471
|
+
".tsx",
|
|
472
|
+
".js",
|
|
473
|
+
".jsx",
|
|
474
|
+
".mjs",
|
|
475
|
+
".cjs",
|
|
476
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
477
|
+
// Frontend Frameworks
|
|
478
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
479
|
+
".vue",
|
|
480
|
+
".svelte",
|
|
481
|
+
".astro",
|
|
482
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
483
|
+
// Python
|
|
484
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
485
|
+
".py",
|
|
486
|
+
".pyw",
|
|
487
|
+
".pyx",
|
|
488
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
489
|
+
// Go
|
|
490
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
491
|
+
".go",
|
|
492
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
493
|
+
// Rust
|
|
494
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
495
|
+
".rs",
|
|
496
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
497
|
+
// Java/Kotlin
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
499
|
+
".java",
|
|
500
|
+
".kt",
|
|
501
|
+
".kts",
|
|
502
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
503
|
+
// C/C++
|
|
504
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
505
|
+
".c",
|
|
506
|
+
".cpp",
|
|
507
|
+
".cc",
|
|
508
|
+
".cxx",
|
|
509
|
+
".h",
|
|
510
|
+
".hpp",
|
|
511
|
+
".hxx",
|
|
512
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
513
|
+
// C#
|
|
514
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
515
|
+
".cs",
|
|
516
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
517
|
+
// Ruby
|
|
518
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
519
|
+
".rb",
|
|
520
|
+
".rake",
|
|
521
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
522
|
+
// PHP
|
|
523
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
524
|
+
".php",
|
|
525
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
526
|
+
// Swift
|
|
527
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
528
|
+
".swift",
|
|
529
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
530
|
+
// Scala
|
|
531
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
532
|
+
".scala",
|
|
533
|
+
".sc",
|
|
534
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
535
|
+
// Elixir/Erlang
|
|
536
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
537
|
+
".ex",
|
|
538
|
+
".exs",
|
|
539
|
+
".erl",
|
|
540
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
541
|
+
// Haskell
|
|
542
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
543
|
+
".hs",
|
|
544
|
+
".lhs",
|
|
545
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
546
|
+
// Lua
|
|
547
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
548
|
+
".lua",
|
|
549
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
550
|
+
// R
|
|
551
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
552
|
+
".r",
|
|
553
|
+
".R",
|
|
554
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
555
|
+
// Shell
|
|
556
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
557
|
+
".sh",
|
|
558
|
+
".bash",
|
|
559
|
+
".zsh",
|
|
560
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
561
|
+
// Dart
|
|
562
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
563
|
+
".dart",
|
|
564
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
565
|
+
// Zig
|
|
566
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
567
|
+
".zig",
|
|
568
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
569
|
+
// Nim
|
|
570
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
571
|
+
".nim",
|
|
572
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
573
|
+
// Crystal
|
|
574
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
575
|
+
".cr",
|
|
576
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
577
|
+
// Julia
|
|
578
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
579
|
+
".jl",
|
|
580
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
581
|
+
// Clojure
|
|
582
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
583
|
+
".clj",
|
|
584
|
+
".cljs",
|
|
585
|
+
".cljc",
|
|
586
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
587
|
+
// F#
|
|
588
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
589
|
+
".fs",
|
|
590
|
+
".fsx",
|
|
591
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
592
|
+
// OCaml
|
|
593
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
594
|
+
".ml",
|
|
595
|
+
".mli",
|
|
596
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
597
|
+
// Perl
|
|
598
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
599
|
+
".pl",
|
|
600
|
+
".pm",
|
|
601
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
602
|
+
// Groovy
|
|
603
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
604
|
+
".groovy",
|
|
605
|
+
".gvy"
|
|
606
|
+
]);
|
|
607
|
+
var LINTABLE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
|
608
|
+
var isLintableFile = (filePath) => LINTABLE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
609
|
+
var JSON_EXTENSIONS = [".json"];
|
|
610
|
+
var isJsonFile = (filePath) => JSON_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
611
|
+
var I18N_LOCALE_PATTERNS = [
|
|
612
|
+
/\/locales\/[a-z]{2}\.json$/i,
|
|
613
|
+
// /locales/en.json, /locales/tr.json
|
|
614
|
+
/\/i18n\/.*\/[a-z]{2}\.json$/i,
|
|
615
|
+
// /i18n/locales/en.json
|
|
616
|
+
/\/translations\/[a-z]{2}\.json$/i,
|
|
617
|
+
// /translations/en.json
|
|
618
|
+
/\.[a-z]{2}\.json$/i
|
|
619
|
+
// file.en.json, file.tr.json
|
|
620
|
+
];
|
|
621
|
+
var isI18nLocaleFile = (filePath) => I18N_LOCALE_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
622
|
+
|
|
623
|
+
// src/constants/patterns.ts
|
|
624
|
+
var DEPRECATED_PATTERN = /deprecated/i;
|
|
625
|
+
var TYPESCRIPT_ERROR_PATTERN = /error TS/;
|
|
626
|
+
|
|
627
|
+
// src/constants/rules.ts
|
|
628
|
+
var RULE_NAMES = {
|
|
629
|
+
TYPESCRIPT: "typescript",
|
|
630
|
+
TYPESCRIPT_DEPRECATED: "typescript:deprecated",
|
|
631
|
+
ESLINT: "eslint"
|
|
632
|
+
};
|
|
633
|
+
var CUSTOM_RULE_PREFIX = "custom:";
|
|
634
|
+
function formatCustomRuleId(ruleId) {
|
|
635
|
+
return `${CUSTOM_RULE_PREFIX}${ruleId}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// src/constants/eslintConfigFilenames.ts
|
|
639
|
+
var ESLINT_PROJECT_ROOT_CONFIG_FILENAMES = [
|
|
640
|
+
"eslint.config.js",
|
|
641
|
+
"eslint.config.mjs",
|
|
642
|
+
"eslint.config.cjs",
|
|
643
|
+
"eslint.config.ts",
|
|
644
|
+
".eslintrc.cjs",
|
|
645
|
+
".eslintrc.js",
|
|
646
|
+
".eslintrc.json",
|
|
647
|
+
".eslintrc.yaml",
|
|
648
|
+
".eslintrc.yml"
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
// src/constants/exit-codes.ts
|
|
652
|
+
var EXIT_CODE = {
|
|
653
|
+
/** Quality checks passed */
|
|
654
|
+
SUCCESS: 0,
|
|
655
|
+
/** Quality checks failed (issues found or verification failed) */
|
|
656
|
+
QUALITY_FAILED: 1,
|
|
657
|
+
/** Configuration error, unexpected exception, or invalid CLI usage */
|
|
658
|
+
ERROR: 2
|
|
659
|
+
};
|
|
660
|
+
var transactionFileSync = {
|
|
661
|
+
readFileSync: fs8.readFileSync.bind(fs8),
|
|
662
|
+
writeFileSync: fs8.writeFileSync.bind(fs8),
|
|
663
|
+
existsSync: fs8.existsSync.bind(fs8),
|
|
664
|
+
unlinkSync: fs8.unlinkSync.bind(fs8)
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// src/core/TransactionManager.ts
|
|
668
|
+
var TransactionImpl = class {
|
|
669
|
+
backups = /* @__PURE__ */ new Map();
|
|
670
|
+
committed = false;
|
|
671
|
+
rolledBack = false;
|
|
672
|
+
/**
|
|
673
|
+
* Record file content before modification
|
|
674
|
+
* Only records once per file (first call wins)
|
|
675
|
+
*/
|
|
676
|
+
recordChange(file) {
|
|
677
|
+
if (this.committed || this.rolledBack) throw new Error("Transaction already finalized");
|
|
678
|
+
const absolutePath = path13.resolve(file);
|
|
679
|
+
if (!this.backups.has(absolutePath)) {
|
|
680
|
+
try {
|
|
681
|
+
const content = transactionFileSync.readFileSync(absolutePath, "utf8");
|
|
682
|
+
this.backups.set(absolutePath, content);
|
|
683
|
+
} catch {
|
|
684
|
+
this.backups.set(absolutePath, "");
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Commit transaction - clear backups, changes are permanent
|
|
690
|
+
*/
|
|
691
|
+
commit() {
|
|
692
|
+
if (this.committed) return Promise.reject(new Error("Transaction already committed"));
|
|
693
|
+
if (this.rolledBack) return Promise.reject(new Error("Transaction already rolled back"));
|
|
694
|
+
this.backups.clear();
|
|
695
|
+
this.committed = true;
|
|
696
|
+
return Promise.resolve();
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Rollback transaction - restore all files to original state
|
|
700
|
+
*/
|
|
701
|
+
rollback() {
|
|
702
|
+
if (this.committed) return Promise.reject(new Error("Transaction already committed"));
|
|
703
|
+
if (this.rolledBack) return Promise.reject(new Error("Transaction already rolled back"));
|
|
704
|
+
const errors = [];
|
|
705
|
+
for (const [filePath, originalContent] of this.backups) {
|
|
706
|
+
try {
|
|
707
|
+
if (originalContent === "") {
|
|
708
|
+
if (transactionFileSync.existsSync(filePath)) transactionFileSync.unlinkSync(filePath);
|
|
709
|
+
} else transactionFileSync.writeFileSync(filePath, originalContent, "utf8");
|
|
710
|
+
} catch (error) {
|
|
711
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
712
|
+
errors.push(`Failed to restore ${filePath}: ${message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
this.backups.clear();
|
|
716
|
+
this.rolledBack = true;
|
|
717
|
+
if (errors.length > 0) return Promise.reject(new Error(`Rollback partially failed: ${errors.join("; ")}`));
|
|
718
|
+
return Promise.resolve();
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Get count of backed up files
|
|
722
|
+
*/
|
|
723
|
+
getBackupCount() {
|
|
724
|
+
return this.backups.size;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Check if file is backed up
|
|
728
|
+
*/
|
|
729
|
+
isBackedUp(file) {
|
|
730
|
+
return this.backups.has(path13.resolve(file));
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var TransactionManager = class {
|
|
734
|
+
/**
|
|
735
|
+
* Begin a new transaction
|
|
736
|
+
*/
|
|
737
|
+
begin() {
|
|
738
|
+
return new TransactionImpl();
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
var BaseFixer = class {
|
|
742
|
+
project;
|
|
743
|
+
constructor() {
|
|
744
|
+
this.project = new Project({
|
|
745
|
+
useInMemoryFileSystem: false,
|
|
746
|
+
skipAddingFilesFromTsConfig: true
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Get or add source file to project
|
|
751
|
+
* Handles the case where file might already be added
|
|
752
|
+
*/
|
|
753
|
+
getSourceFile(filePath) {
|
|
754
|
+
try {
|
|
755
|
+
return this.project.addSourceFileAtPath(filePath);
|
|
756
|
+
} catch {
|
|
757
|
+
return this.project.getSourceFileOrThrow(filePath);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Cleanup: Remove source file from project to avoid memory leaks
|
|
762
|
+
*/
|
|
763
|
+
cleanupSourceFile(sourceFile) {
|
|
764
|
+
this.project.removeSourceFile(sourceFile);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// src/types/config.ts
|
|
769
|
+
var DEFAULT_FIXER_CONFIG = {
|
|
770
|
+
eslint: true,
|
|
771
|
+
curlyBraces: true,
|
|
772
|
+
singleLineArrow: true,
|
|
773
|
+
prettier: true,
|
|
774
|
+
jsonValidator: true
|
|
775
|
+
};
|
|
776
|
+
var MAX_LINE_LENGTH = 120;
|
|
777
|
+
var CurlyBracesFixer = class extends BaseFixer {
|
|
778
|
+
name = FIXER_TYPE.CURLY_BRACES;
|
|
779
|
+
/**
|
|
780
|
+
* Scan file for if statements with unnecessary braces
|
|
781
|
+
*/
|
|
782
|
+
async scanAndFix(filePath, transaction) {
|
|
783
|
+
const fixes = [];
|
|
784
|
+
try {
|
|
785
|
+
const sourceFile = this.getSourceFile(filePath);
|
|
786
|
+
const ifStatements = this.findIfStatements(sourceFile);
|
|
787
|
+
const sortedStatements = [...ifStatements].sort((a, b) => b.getStart() - a.getStart());
|
|
788
|
+
for (const ifStmt of sortedStatements) {
|
|
789
|
+
if (!this.isSafeToTransform(ifStmt)) continue;
|
|
790
|
+
const lineNumber = ifStmt.getStartLineNumber();
|
|
791
|
+
transaction.recordChange(filePath);
|
|
792
|
+
const result = this.transform(ifStmt);
|
|
793
|
+
if (result.success) {
|
|
794
|
+
fixes.push({
|
|
795
|
+
file: filePath,
|
|
796
|
+
line: lineNumber,
|
|
797
|
+
type: FIXER_TYPE.CURLY_BRACES,
|
|
798
|
+
description: "Removed unnecessary curly braces from single-statement if"
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (fixes.length > 0) await sourceFile.save();
|
|
803
|
+
this.cleanupSourceFile(sourceFile);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
console.error(`CurlyBracesFixer error in ${filePath}:`, error);
|
|
806
|
+
}
|
|
807
|
+
return fixes;
|
|
808
|
+
}
|
|
809
|
+
findIfStatements(sourceFile) {
|
|
810
|
+
return sourceFile.getDescendantsOfKind(SyntaxKind.IfStatement);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Check if if statement is safe to transform (remove braces)
|
|
814
|
+
*/
|
|
815
|
+
isSafeToTransform(ifStmt) {
|
|
816
|
+
if (ifStmt.getElseStatement()) return false;
|
|
817
|
+
const thenStmt = ifStmt.getThenStatement();
|
|
818
|
+
if (thenStmt.getKind() !== SyntaxKind.Block) return false;
|
|
819
|
+
const block = thenStmt;
|
|
820
|
+
const statements = block.getStatements();
|
|
821
|
+
if (statements.length !== 1) return false;
|
|
822
|
+
const singleStatement = statements[0];
|
|
823
|
+
if (!singleStatement) return false;
|
|
824
|
+
if (singleStatement.getKind() === SyntaxKind.IfStatement) return false;
|
|
825
|
+
const blockText = block.getText();
|
|
826
|
+
if (blockText.includes("//") || blockText.includes("/*")) return false;
|
|
827
|
+
const statementText = singleStatement.getText().replace(/;$/, "");
|
|
828
|
+
if (this.containsObjectLiteralReturn(statementText)) return false;
|
|
829
|
+
const conditionText = ifStmt.getExpression().getText();
|
|
830
|
+
const combinedLine = `if (${conditionText}) ${statementText}`;
|
|
831
|
+
const indent = this.getIndentation(ifStmt);
|
|
832
|
+
if (indent.length + combinedLine.length > MAX_LINE_LENGTH) return false;
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
getIndentation(ifStmt) {
|
|
836
|
+
const fullText = ifStmt.getFullText();
|
|
837
|
+
const leadingWhitespace = /^[\t ]*/.exec(fullText)?.[0] || "";
|
|
838
|
+
return leadingWhitespace.replaceAll("\n", "");
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Check if statement returns an object literal
|
|
842
|
+
* Prettier wraps object literals to multiple lines based on various heuristics
|
|
843
|
+
* (not just printWidth), which causes ESLint curly: multi-line errors
|
|
844
|
+
*
|
|
845
|
+
* Skip ANY return statement with object literal to be safe
|
|
846
|
+
*/
|
|
847
|
+
containsObjectLiteralReturn(statementText) {
|
|
848
|
+
return /^return\s+\{/.test(statementText.trim());
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Transform if statement to single line without braces
|
|
852
|
+
*/
|
|
853
|
+
transform(ifStmt) {
|
|
854
|
+
try {
|
|
855
|
+
const conditionText = ifStmt.getExpression().getText();
|
|
856
|
+
const block = ifStmt.getThenStatement();
|
|
857
|
+
const singleStatement = block.getStatements()[0];
|
|
858
|
+
if (!singleStatement) return { success: false, error: "No statement found" };
|
|
859
|
+
const statementText = singleStatement.getText().replace(/;$/, "");
|
|
860
|
+
const newText = `if (${conditionText}) ${statementText}`;
|
|
861
|
+
ifStmt.replaceWithText(newText);
|
|
862
|
+
return { success: true };
|
|
863
|
+
} catch (error) {
|
|
864
|
+
return {
|
|
865
|
+
success: false,
|
|
866
|
+
error: error instanceof Error ? error.message : String(error)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
var MAX_LINE_LENGTH2 = 120;
|
|
872
|
+
var ASSIGNMENT_OPERATORS = /* @__PURE__ */ new Set([
|
|
873
|
+
SyntaxKind.EqualsToken,
|
|
874
|
+
SyntaxKind.PlusEqualsToken,
|
|
875
|
+
SyntaxKind.MinusEqualsToken,
|
|
876
|
+
SyntaxKind.AsteriskEqualsToken,
|
|
877
|
+
SyntaxKind.SlashEqualsToken,
|
|
878
|
+
SyntaxKind.PercentEqualsToken,
|
|
879
|
+
SyntaxKind.AmpersandEqualsToken,
|
|
880
|
+
SyntaxKind.BarEqualsToken,
|
|
881
|
+
SyntaxKind.CaretEqualsToken,
|
|
882
|
+
SyntaxKind.LessThanLessThanEqualsToken,
|
|
883
|
+
SyntaxKind.GreaterThanGreaterThanEqualsToken,
|
|
884
|
+
SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
|
|
885
|
+
SyntaxKind.AsteriskAsteriskEqualsToken,
|
|
886
|
+
SyntaxKind.BarBarEqualsToken,
|
|
887
|
+
SyntaxKind.AmpersandAmpersandEqualsToken,
|
|
888
|
+
SyntaxKind.QuestionQuestionEqualsToken
|
|
889
|
+
]);
|
|
890
|
+
var SingleLineArrowFixer = class extends BaseFixer {
|
|
891
|
+
name = FIXER_TYPE.SINGLE_LINE_ARROW;
|
|
892
|
+
/**
|
|
893
|
+
* Scan file for arrow functions with unnecessary braces
|
|
894
|
+
*/
|
|
895
|
+
async scanAndFix(filePath, transaction) {
|
|
896
|
+
const fixes = [];
|
|
897
|
+
if (filePath.endsWith(".vue")) return fixes;
|
|
898
|
+
try {
|
|
899
|
+
const sourceFile = this.getSourceFile(filePath);
|
|
900
|
+
const arrowFunctions = this.findArrowFunctions(sourceFile);
|
|
901
|
+
const sortedFunctions = [...arrowFunctions].sort((a, b) => b.getStart() - a.getStart());
|
|
902
|
+
for (const arrowFn of sortedFunctions) {
|
|
903
|
+
const result = this.processArrowFunction(arrowFn, filePath, transaction);
|
|
904
|
+
if (result) fixes.push(result);
|
|
905
|
+
}
|
|
906
|
+
if (fixes.length > 0) await sourceFile.save();
|
|
907
|
+
this.cleanupSourceFile(sourceFile);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
console.error(`SingleLineArrowFixer error in ${filePath}:`, error);
|
|
910
|
+
}
|
|
911
|
+
return fixes;
|
|
912
|
+
}
|
|
913
|
+
findArrowFunctions(sourceFile) {
|
|
914
|
+
return sourceFile.getDescendantsOfKind(SyntaxKind.ArrowFunction);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Process single arrow function
|
|
918
|
+
*/
|
|
919
|
+
processArrowFunction(arrowFn, filePath, transaction) {
|
|
920
|
+
if (!this.hasBlockBody(arrowFn)) return null;
|
|
921
|
+
const block = arrowFn.getBody();
|
|
922
|
+
const statement = this.getSingleStatement(block);
|
|
923
|
+
if (!statement) return null;
|
|
924
|
+
if (this.hasComments(block)) return null;
|
|
925
|
+
const newBody = this.extractNewBody(statement);
|
|
926
|
+
if (!newBody) return null;
|
|
927
|
+
if (this.wouldExceedLineLength(arrowFn, newBody)) return null;
|
|
928
|
+
const lineNumber = arrowFn.getStartLineNumber();
|
|
929
|
+
transaction.recordChange(filePath);
|
|
930
|
+
const result = this.transformArrowFunction(arrowFn, newBody);
|
|
931
|
+
if (!result.success) return null;
|
|
932
|
+
return {
|
|
933
|
+
file: filePath,
|
|
934
|
+
line: lineNumber,
|
|
935
|
+
type: FIXER_TYPE.SINGLE_LINE_ARROW,
|
|
936
|
+
description: "Converted multi-line arrow function to single line"
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Check if arrow function has block body (with braces)
|
|
941
|
+
*/
|
|
942
|
+
hasBlockBody(arrowFn) {
|
|
943
|
+
return arrowFn.getBody().getKind() === SyntaxKind.Block;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Get single statement from block, or null if not exactly one
|
|
947
|
+
*/
|
|
948
|
+
getSingleStatement(block) {
|
|
949
|
+
const statements = block.getStatements();
|
|
950
|
+
if (statements.length !== 1) return null;
|
|
951
|
+
const stmt = statements[0];
|
|
952
|
+
if (!stmt) return null;
|
|
953
|
+
const kind = stmt.getKind();
|
|
954
|
+
if (kind !== SyntaxKind.ReturnStatement && kind !== SyntaxKind.ExpressionStatement) return null;
|
|
955
|
+
return stmt;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Check if block has comments
|
|
959
|
+
*/
|
|
960
|
+
hasComments(block) {
|
|
961
|
+
const text = block.getText();
|
|
962
|
+
return text.includes("//") || text.includes("/*");
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Extract new body from statement
|
|
966
|
+
*/
|
|
967
|
+
extractNewBody(statement) {
|
|
968
|
+
const kind = statement.getKind();
|
|
969
|
+
if (kind === SyntaxKind.ReturnStatement) return this.extractFromReturn(statement);
|
|
970
|
+
if (kind === SyntaxKind.ExpressionStatement) return this.extractFromExpression(statement);
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Extract body from return statement
|
|
975
|
+
*/
|
|
976
|
+
extractFromReturn(returnStmt) {
|
|
977
|
+
const expr = returnStmt.getExpression();
|
|
978
|
+
if (!expr) return "undefined";
|
|
979
|
+
const exprText = expr.getText();
|
|
980
|
+
if (expr.getKind() === SyntaxKind.ObjectLiteralExpression) return `(${exprText})`;
|
|
981
|
+
return exprText;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Extract body from expression statement
|
|
985
|
+
*/
|
|
986
|
+
extractFromExpression(exprStmt) {
|
|
987
|
+
const expr = exprStmt.getExpression();
|
|
988
|
+
const exprText = expr.getText().replace(/;$/, "");
|
|
989
|
+
if (expr.getKind() === SyntaxKind.BinaryExpression) {
|
|
990
|
+
const binaryExpr = expr;
|
|
991
|
+
const operatorKind = binaryExpr.getOperatorToken().getKind();
|
|
992
|
+
if (ASSIGNMENT_OPERATORS.has(operatorKind)) return `(${exprText})`;
|
|
993
|
+
}
|
|
994
|
+
return exprText;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Check if transformed line would exceed max length
|
|
998
|
+
*/
|
|
999
|
+
wouldExceedLineLength(arrowFn, newBody) {
|
|
1000
|
+
const params = arrowFn.getParameters().map((p) => p.getText()).join(", ");
|
|
1001
|
+
const asyncKeyword = arrowFn.isAsync() ? "async " : "";
|
|
1002
|
+
const needsParens = arrowFn.getParameters().length !== 1 || arrowFn.getParameters()[0]?.getTypeNode() !== void 0;
|
|
1003
|
+
const paramsText = needsParens ? `(${params})` : params;
|
|
1004
|
+
const combinedLine = `${asyncKeyword}${paramsText} => ${newBody}`;
|
|
1005
|
+
const parent = arrowFn.getParent();
|
|
1006
|
+
let prefix = "";
|
|
1007
|
+
if (parent.getKind() === SyntaxKind.VariableDeclaration) {
|
|
1008
|
+
const firstChild = parent.getChildAtIndex(0);
|
|
1009
|
+
const varName = firstChild.getText();
|
|
1010
|
+
prefix = `const ${varName} = `;
|
|
1011
|
+
}
|
|
1012
|
+
const indent = this.getIndentation(arrowFn);
|
|
1013
|
+
return indent.length + prefix.length + combinedLine.length > MAX_LINE_LENGTH2;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Get indentation of arrow function
|
|
1017
|
+
*/
|
|
1018
|
+
getIndentation(arrowFn) {
|
|
1019
|
+
let current = arrowFn.getParent();
|
|
1020
|
+
while (current !== void 0) {
|
|
1021
|
+
if (current.getKind() === SyntaxKind.VariableStatement) {
|
|
1022
|
+
const fullText = current.getFullText();
|
|
1023
|
+
const match = /^[\t ]*/.exec(fullText);
|
|
1024
|
+
const whitespace = match?.[0] ?? "";
|
|
1025
|
+
return whitespace.replaceAll("\n", "");
|
|
1026
|
+
}
|
|
1027
|
+
current = current.getParent();
|
|
1028
|
+
}
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Transform arrow function to single line
|
|
1033
|
+
*/
|
|
1034
|
+
transformArrowFunction(arrowFn, newBody) {
|
|
1035
|
+
try {
|
|
1036
|
+
const params = arrowFn.getParameters().map((p) => p.getText()).join(", ");
|
|
1037
|
+
const asyncKeyword = arrowFn.isAsync() ? "async " : "";
|
|
1038
|
+
const needsParens = arrowFn.getParameters().length !== 1 || arrowFn.getParameters()[0]?.getTypeNode() !== void 0;
|
|
1039
|
+
const paramsText = needsParens ? `(${params})` : params;
|
|
1040
|
+
const newText = `${asyncKeyword}${paramsText} => ${newBody}`;
|
|
1041
|
+
arrowFn.replaceWithText(newText);
|
|
1042
|
+
return { success: true };
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
return {
|
|
1045
|
+
success: false,
|
|
1046
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// src/fixers/AutoFixer.ts
|
|
1053
|
+
var AutoFixer = class {
|
|
1054
|
+
fixers;
|
|
1055
|
+
constructor(fixerConfig = DEFAULT_FIXER_CONFIG) {
|
|
1056
|
+
this.fixers = [];
|
|
1057
|
+
if (fixerConfig.curlyBraces) this.fixers.push(new CurlyBracesFixer());
|
|
1058
|
+
if (fixerConfig.singleLineArrow) this.fixers.push(new SingleLineArrowFixer());
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Scan all files and apply fixes
|
|
1062
|
+
*
|
|
1063
|
+
* @param files - Array of file paths to scan
|
|
1064
|
+
* @param transaction - Transaction for rollback support
|
|
1065
|
+
* @returns Summary of fixes applied
|
|
1066
|
+
*/
|
|
1067
|
+
async scanAndFix(files, transaction) {
|
|
1068
|
+
const summary = {
|
|
1069
|
+
eslint: 0,
|
|
1070
|
+
curlyBraces: 0,
|
|
1071
|
+
singleLineArrow: 0,
|
|
1072
|
+
prettier: 0,
|
|
1073
|
+
json: 0
|
|
1074
|
+
};
|
|
1075
|
+
const eligibleFiles = this.filterEligibleFiles(files);
|
|
1076
|
+
for (const file of eligibleFiles) {
|
|
1077
|
+
await this.processFileWithFixers(file, transaction, summary);
|
|
1078
|
+
}
|
|
1079
|
+
return summary;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Filter to TypeScript/JavaScript files only
|
|
1083
|
+
*/
|
|
1084
|
+
filterEligibleFiles(files) {
|
|
1085
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
1086
|
+
return files.filter((file) => extensions.some((ext) => file.endsWith(ext)));
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Process single file with all fixers
|
|
1090
|
+
*/
|
|
1091
|
+
async processFileWithFixers(file, transaction, summary) {
|
|
1092
|
+
for (const fixer of this.fixers) {
|
|
1093
|
+
await this.runFixer(fixer, file, transaction, summary);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Run single fixer on file
|
|
1098
|
+
*/
|
|
1099
|
+
async runFixer(fixer, file, transaction, summary) {
|
|
1100
|
+
try {
|
|
1101
|
+
const fixes = await fixer.scanAndFix(file, transaction);
|
|
1102
|
+
this.updateSummary(summary, fixer.name, fixes.length);
|
|
1103
|
+
if (fixes.length > 0) console.error(`[AutoFixer] ${fixer.name}: ${fixes.length} fixes in ${file}`);
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
console.error(`[AutoFixer] Error in ${fixer.name} for ${file}:`, error);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Update summary based on fixer type
|
|
1110
|
+
*/
|
|
1111
|
+
updateSummary(summary, fixerName, count) {
|
|
1112
|
+
if (fixerName === FIXER_TYPE.CURLY_BRACES) summary.curlyBraces += count;
|
|
1113
|
+
if (fixerName === FIXER_TYPE.SINGLE_LINE_ARROW) summary.singleLineArrow += count;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Get list of registered fixers
|
|
1117
|
+
*/
|
|
1118
|
+
getFixerNames() {
|
|
1119
|
+
return this.fixers.map((f) => f.name);
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
new AutoFixer(DEFAULT_FIXER_CONFIG);
|
|
1123
|
+
function compileRulePattern(pattern, ruleId) {
|
|
1124
|
+
try {
|
|
1125
|
+
return new RegExp(pattern, "g");
|
|
1126
|
+
} catch {
|
|
1127
|
+
console.error(`[ai-quality-gate] Invalid custom rule pattern (skipped): id=${ruleId}, pattern=${pattern}`);
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
function pushMatchesOnLine(issues, line, lineIndex0, regex, ctx) {
|
|
1132
|
+
regex.lastIndex = 0;
|
|
1133
|
+
let match;
|
|
1134
|
+
while ((match = regex.exec(line)) !== null) {
|
|
1135
|
+
issues.push({
|
|
1136
|
+
rule: formatCustomRuleId(ctx.rule.id),
|
|
1137
|
+
file: ctx.normalizedFile,
|
|
1138
|
+
line: lineIndex0 + 1,
|
|
1139
|
+
column: match.index + 1,
|
|
1140
|
+
message: ctx.rule.message,
|
|
1141
|
+
severity: ctx.rule.severity
|
|
1142
|
+
});
|
|
1143
|
+
if (match[0].length === 0) {
|
|
1144
|
+
if (regex.lastIndex === match.index) regex.lastIndex += 1;
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
async function scanFileWithRule(rule, regex, file) {
|
|
1150
|
+
let content;
|
|
1151
|
+
try {
|
|
1152
|
+
content = await readFile(file, "utf8");
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
console.error(`[ai-quality-gate] Could not read file for custom rules: ${file}`, error);
|
|
1155
|
+
return [];
|
|
1156
|
+
}
|
|
1157
|
+
const lines = content.split(/\r?\n/);
|
|
1158
|
+
const normalizedPath = path13.normalize(file);
|
|
1159
|
+
const ctx = { rule, normalizedFile: normalizedPath };
|
|
1160
|
+
const issues = [];
|
|
1161
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
1162
|
+
pushMatchesOnLine(issues, line, lineIndex, regex, ctx);
|
|
1163
|
+
}
|
|
1164
|
+
return issues;
|
|
1165
|
+
}
|
|
1166
|
+
var CustomRulesValidator = class {
|
|
1167
|
+
async validate(rules, files) {
|
|
1168
|
+
if (rules.length === 0 || files.length === 0) return [];
|
|
1169
|
+
const issues = [];
|
|
1170
|
+
for (const rule of rules) {
|
|
1171
|
+
const regex = compileRulePattern(rule.pattern, rule.id);
|
|
1172
|
+
if (regex === null) continue;
|
|
1173
|
+
for (const file of files) {
|
|
1174
|
+
const fileIssues = await scanFileWithRule(rule, regex, file);
|
|
1175
|
+
issues.push(...fileIssues);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return issues;
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
var BOM_CODE_POINT = 65279;
|
|
1182
|
+
var MIN_LOCALE_FILES_FOR_CONSISTENCY = 2;
|
|
1183
|
+
var JsonValidator = class {
|
|
1184
|
+
/**
|
|
1185
|
+
* Validate JSON files
|
|
1186
|
+
*
|
|
1187
|
+
* @param files - Array of file paths to validate
|
|
1188
|
+
* @returns Validation result. `passed` reflects blocking issues only (`issues`). Locale key consistency
|
|
1189
|
+
* is listed separately in `i18nIssues` and does not flip `passed` to `false`.
|
|
1190
|
+
*/
|
|
1191
|
+
async validate(files) {
|
|
1192
|
+
const issues = [];
|
|
1193
|
+
const i18nIssues = [];
|
|
1194
|
+
let validCount = 0;
|
|
1195
|
+
const i18nGroups = /* @__PURE__ */ new Map();
|
|
1196
|
+
for (const file of files) {
|
|
1197
|
+
const result = await this.validateSingleFile(file);
|
|
1198
|
+
if (result.passed) {
|
|
1199
|
+
validCount++;
|
|
1200
|
+
this.trackI18nFile(file, i18nGroups);
|
|
1201
|
+
} else if (result.error) issues.push(this.toIssue(result.error));
|
|
1202
|
+
}
|
|
1203
|
+
const consistencyIssues = await this.checkAllI18nGroups(i18nGroups);
|
|
1204
|
+
i18nIssues.push(...consistencyIssues);
|
|
1205
|
+
return {
|
|
1206
|
+
passed: issues.length === 0,
|
|
1207
|
+
validCount,
|
|
1208
|
+
issues,
|
|
1209
|
+
i18nIssues
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Track i18n file for consistency checking
|
|
1214
|
+
*/
|
|
1215
|
+
trackI18nFile(file, groups) {
|
|
1216
|
+
if (!isI18nLocaleFile(file)) return;
|
|
1217
|
+
const dir = path13.dirname(file);
|
|
1218
|
+
const group = groups.get(dir) ?? [];
|
|
1219
|
+
group.push(file);
|
|
1220
|
+
groups.set(dir, group);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Check i18n consistency for all groups
|
|
1224
|
+
*/
|
|
1225
|
+
async checkAllI18nGroups(groups) {
|
|
1226
|
+
const allIssues = [];
|
|
1227
|
+
for (const [, localeFiles] of groups) {
|
|
1228
|
+
if (localeFiles.length >= MIN_LOCALE_FILES_FOR_CONSISTENCY) {
|
|
1229
|
+
const issues = await this.checkI18nConsistency(localeFiles);
|
|
1230
|
+
allIssues.push(...issues);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
return allIssues;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Validate a single JSON file
|
|
1237
|
+
*/
|
|
1238
|
+
async validateSingleFile(filePath) {
|
|
1239
|
+
try {
|
|
1240
|
+
const content = await fs4.readFile(filePath, "utf8");
|
|
1241
|
+
if (this.hasBom(content)) {
|
|
1242
|
+
return {
|
|
1243
|
+
passed: false,
|
|
1244
|
+
error: {
|
|
1245
|
+
file: filePath,
|
|
1246
|
+
line: 1,
|
|
1247
|
+
column: 1,
|
|
1248
|
+
message: "File contains BOM (Byte Order Mark). Remove it for clean JSON."
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
this.parseJson(content);
|
|
1253
|
+
return { passed: true };
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
return {
|
|
1256
|
+
passed: false,
|
|
1257
|
+
error: this.extractParseError(error, filePath)
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Check if content has BOM
|
|
1263
|
+
*/
|
|
1264
|
+
hasBom(content) {
|
|
1265
|
+
return content.codePointAt(0) === BOM_CODE_POINT;
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Parse JSON content
|
|
1269
|
+
*/
|
|
1270
|
+
parseJson(content) {
|
|
1271
|
+
return JSON.parse(content);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Extract detailed error information from JSON parse error
|
|
1275
|
+
*/
|
|
1276
|
+
extractParseError(error, filePath) {
|
|
1277
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1278
|
+
const { line, column } = this.extractLineColumn(message);
|
|
1279
|
+
return {
|
|
1280
|
+
file: filePath,
|
|
1281
|
+
line,
|
|
1282
|
+
column,
|
|
1283
|
+
message: this.friendlyErrorMessage(message)
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Extract line and column from error message
|
|
1288
|
+
*/
|
|
1289
|
+
extractLineColumn(message) {
|
|
1290
|
+
let line = 1;
|
|
1291
|
+
let column = 1;
|
|
1292
|
+
const positionMatch = /at position (\d+)/.exec(message);
|
|
1293
|
+
if (positionMatch?.[1]) column = Number.parseInt(positionMatch[1], 10);
|
|
1294
|
+
const lineMatch = /line (\d+)/.exec(message);
|
|
1295
|
+
const columnMatch = /column (\d+)/.exec(message);
|
|
1296
|
+
if (lineMatch?.[1]) line = Number.parseInt(lineMatch[1], 10);
|
|
1297
|
+
if (columnMatch?.[1]) column = Number.parseInt(columnMatch[1], 10);
|
|
1298
|
+
return { line, column };
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Convert JSON parse error to user-friendly message
|
|
1302
|
+
*/
|
|
1303
|
+
friendlyErrorMessage(message) {
|
|
1304
|
+
if (message.includes("Unexpected token")) return this.friendlyUnexpectedTokenMessage(message);
|
|
1305
|
+
if (message.includes("Unexpected end")) return "Incomplete JSON - missing closing bracket or brace";
|
|
1306
|
+
if (message.includes("Duplicate keys")) return message;
|
|
1307
|
+
return `JSON parse error: ${message}`;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Get friendly message for unexpected token errors
|
|
1311
|
+
*/
|
|
1312
|
+
friendlyUnexpectedTokenMessage(message) {
|
|
1313
|
+
if (message.includes(",")) return "Trailing comma or extra comma in JSON (not allowed in strict JSON)";
|
|
1314
|
+
if (message.includes("}")) return "Unexpected closing brace - check for missing values or extra commas";
|
|
1315
|
+
if (message.includes("]")) return "Unexpected closing bracket - check for missing values or extra commas";
|
|
1316
|
+
return "Invalid JSON syntax - check for typos or missing quotes";
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Check i18n consistency across locale files
|
|
1320
|
+
* Ensures all locale files have the same keys
|
|
1321
|
+
*/
|
|
1322
|
+
async checkI18nConsistency(localeFiles) {
|
|
1323
|
+
const localeData = await this.parseLocaleFiles(localeFiles);
|
|
1324
|
+
if (localeData.size < MIN_LOCALE_FILES_FOR_CONSISTENCY) return [];
|
|
1325
|
+
return this.compareLocaleKeys(localeData);
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Parse all locale files and extract keys
|
|
1329
|
+
*/
|
|
1330
|
+
async parseLocaleFiles(localeFiles) {
|
|
1331
|
+
const localeData = /* @__PURE__ */ new Map();
|
|
1332
|
+
for (const file of localeFiles) {
|
|
1333
|
+
const keys = await this.extractKeysFromFile(file);
|
|
1334
|
+
if (keys) localeData.set(file, keys);
|
|
1335
|
+
}
|
|
1336
|
+
return localeData;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Extract keys from a single locale file
|
|
1340
|
+
*/
|
|
1341
|
+
async extractKeysFromFile(file) {
|
|
1342
|
+
try {
|
|
1343
|
+
const content = await fs4.readFile(file, "utf8");
|
|
1344
|
+
const parsed = JSON.parse(content);
|
|
1345
|
+
const keys = this.extractAllKeys(parsed);
|
|
1346
|
+
return new Set(keys);
|
|
1347
|
+
} catch {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Compare keys across all locale files
|
|
1353
|
+
*/
|
|
1354
|
+
compareLocaleKeys(localeData) {
|
|
1355
|
+
const entries = [...localeData.entries()];
|
|
1356
|
+
const firstEntry = entries[0];
|
|
1357
|
+
if (!firstEntry) return [];
|
|
1358
|
+
const [referenceFile, referenceKeys] = firstEntry;
|
|
1359
|
+
const issues = [];
|
|
1360
|
+
for (const [file, keys] of entries.slice(1)) {
|
|
1361
|
+
const missingKeys = this.findMissingKeys(file, keys, referenceFile, referenceKeys);
|
|
1362
|
+
const extraKeys = this.findExtraKeys(file, keys, referenceFile, referenceKeys);
|
|
1363
|
+
issues.push(...missingKeys, ...extraKeys);
|
|
1364
|
+
}
|
|
1365
|
+
return issues;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Find keys missing in target file
|
|
1369
|
+
*/
|
|
1370
|
+
findMissingKeys(file, keys, referenceFile, referenceKeys) {
|
|
1371
|
+
const issues = [];
|
|
1372
|
+
for (const key of referenceKeys) {
|
|
1373
|
+
if (!keys.has(key)) issues.push({ file, type: "missing_key", key, referenceFile });
|
|
1374
|
+
}
|
|
1375
|
+
return issues;
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* Find extra keys in target file
|
|
1379
|
+
*/
|
|
1380
|
+
findExtraKeys(file, keys, referenceFile, referenceKeys) {
|
|
1381
|
+
const issues = [];
|
|
1382
|
+
for (const key of keys) {
|
|
1383
|
+
if (!referenceKeys.has(key)) issues.push({ file, type: "extra_key", key, referenceFile });
|
|
1384
|
+
}
|
|
1385
|
+
return issues;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Extract all keys from nested object (dot notation)
|
|
1389
|
+
*/
|
|
1390
|
+
extractAllKeys(obj, prefix = "") {
|
|
1391
|
+
const keys = [];
|
|
1392
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1393
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
1394
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1395
|
+
keys.push(...this.extractAllKeys(value, fullKey));
|
|
1396
|
+
} else {
|
|
1397
|
+
keys.push(fullKey);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
return keys;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Convert JsonParseError to Issue format
|
|
1404
|
+
*/
|
|
1405
|
+
toIssue(error) {
|
|
1406
|
+
return {
|
|
1407
|
+
rule: "json/parse-error",
|
|
1408
|
+
file: error.file,
|
|
1409
|
+
line: error.line,
|
|
1410
|
+
column: error.column,
|
|
1411
|
+
message: error.message,
|
|
1412
|
+
severity: "error"
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
// src/phases/Phase1Local.ts
|
|
1418
|
+
var MAX_I18N_ISSUES_TO_DISPLAY = 5;
|
|
1419
|
+
var Phase1Local = class {
|
|
1420
|
+
config;
|
|
1421
|
+
verifier;
|
|
1422
|
+
autoFixer;
|
|
1423
|
+
jsonValidator;
|
|
1424
|
+
customRulesValidator;
|
|
1425
|
+
constructor(config) {
|
|
1426
|
+
this.config = config;
|
|
1427
|
+
this.verifier = new Verifier(config);
|
|
1428
|
+
this.autoFixer = new AutoFixer(config.fixers);
|
|
1429
|
+
this.jsonValidator = new JsonValidator();
|
|
1430
|
+
this.customRulesValidator = new CustomRulesValidator();
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Run Phase 1 local analysis
|
|
1434
|
+
* @param options.phase1Mode `check` = typecheck + lint verify only (no file mutations). Default `fix`.
|
|
1435
|
+
*
|
|
1436
|
+
* **i18n locale key consistency** (JSON validator): extra/missing keys across locale files are reported
|
|
1437
|
+
* via stderr warnings only; they do **not** set `passed: false` for Phase 1.
|
|
1438
|
+
*/
|
|
1439
|
+
async run(files, transaction, options) {
|
|
1440
|
+
const fixSummary = this.createEmptyFixSummary();
|
|
1441
|
+
const { jsonFiles, codeFiles } = this.categorizeFiles(files);
|
|
1442
|
+
const phase1Mode = options?.phase1Mode ?? "fix";
|
|
1443
|
+
const jsonResult = await this.runJsonValidation(jsonFiles, fixSummary);
|
|
1444
|
+
if (jsonResult) return jsonResult;
|
|
1445
|
+
if (codeFiles.length === 0) {
|
|
1446
|
+
return { passed: true, fixed: fixSummary, issues: [] };
|
|
1447
|
+
}
|
|
1448
|
+
if (phase1Mode === "check") return this.runCodeAnalysisCheck(codeFiles, fixSummary);
|
|
1449
|
+
return this.runCodeAnalysis(codeFiles, transaction, fixSummary);
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Create empty fix summary
|
|
1453
|
+
*/
|
|
1454
|
+
createEmptyFixSummary() {
|
|
1455
|
+
return {
|
|
1456
|
+
eslint: 0,
|
|
1457
|
+
curlyBraces: 0,
|
|
1458
|
+
singleLineArrow: 0,
|
|
1459
|
+
prettier: 0,
|
|
1460
|
+
json: 0
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Categorize files by type
|
|
1465
|
+
*/
|
|
1466
|
+
categorizeFiles(files) {
|
|
1467
|
+
return {
|
|
1468
|
+
jsonFiles: files.filter((f) => isJsonFile(f)),
|
|
1469
|
+
codeFiles: files.filter((f) => isLintableFile(f))
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Run JSON validation and return early if parse/BOM (or other blocking) issues exist.
|
|
1474
|
+
* i18n key mismatches never return early here — they are logged in {@link logI18nIssues} only.
|
|
1475
|
+
*/
|
|
1476
|
+
async runJsonValidation(jsonFiles, fixSummary) {
|
|
1477
|
+
if (jsonFiles.length === 0) return null;
|
|
1478
|
+
if (!this.config.fixers.jsonValidator) return null;
|
|
1479
|
+
const jsonResult = await this.jsonValidator.validate(jsonFiles);
|
|
1480
|
+
fixSummary.json = jsonResult.validCount;
|
|
1481
|
+
if (!jsonResult.passed) {
|
|
1482
|
+
return { passed: false, fixed: fixSummary, issues: jsonResult.issues };
|
|
1483
|
+
}
|
|
1484
|
+
this.logI18nIssues(jsonResult);
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Run regex custom rules from config (optional).
|
|
1489
|
+
*/
|
|
1490
|
+
runCustomRulesPhase(codeFiles) {
|
|
1491
|
+
const rules = this.config.customRules;
|
|
1492
|
+
if (!rules?.length) return Promise.resolve([]);
|
|
1493
|
+
return this.customRulesValidator.validate(rules, codeFiles);
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Log i18n locale key mismatches to stderr. Does not affect Phase 1 success — informational only.
|
|
1497
|
+
*/
|
|
1498
|
+
logI18nIssues(jsonResult) {
|
|
1499
|
+
if (jsonResult.i18nIssues.length === 0) return;
|
|
1500
|
+
console.warn(`\u26A0\uFE0F i18n consistency issues found: ${jsonResult.i18nIssues.length}`);
|
|
1501
|
+
for (const issue of jsonResult.i18nIssues.slice(0, MAX_I18N_ISSUES_TO_DISPLAY)) {
|
|
1502
|
+
console.warn(` - ${issue.file}: ${issue.type} "${issue.key}"`);
|
|
1503
|
+
}
|
|
1504
|
+
if (jsonResult.i18nIssues.length > MAX_I18N_ISSUES_TO_DISPLAY) {
|
|
1505
|
+
const remaining = jsonResult.i18nIssues.length - MAX_I18N_ISSUES_TO_DISPLAY;
|
|
1506
|
+
console.warn(` ... and ${remaining} more`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Run code analysis (TypeScript, AST fixers, ESLint, Prettier)
|
|
1511
|
+
*/
|
|
1512
|
+
async runCodeAnalysis(codeFiles, transaction, fixSummary) {
|
|
1513
|
+
const typecheck = await this.verifier.runTypeCheck(codeFiles);
|
|
1514
|
+
if (!typecheck.passed) {
|
|
1515
|
+
return { passed: false, fixed: fixSummary, issues: typecheck.errors };
|
|
1516
|
+
}
|
|
1517
|
+
const customIssuesEarly = await this.runCustomRulesPhase(codeFiles);
|
|
1518
|
+
if (customIssuesEarly.length > 0) {
|
|
1519
|
+
return { passed: false, fixed: fixSummary, issues: customIssuesEarly };
|
|
1520
|
+
}
|
|
1521
|
+
this.ensureTransactionPrimedForMutablePhase(codeFiles, transaction);
|
|
1522
|
+
const { fixers } = this.config;
|
|
1523
|
+
if (fixers.curlyBraces || fixers.singleLineArrow) await this.runAstFixers(codeFiles, transaction, fixSummary);
|
|
1524
|
+
if (fixers.eslint) {
|
|
1525
|
+
const lintFix = await this.verifier.runLintFix(codeFiles);
|
|
1526
|
+
fixSummary.eslint = lintFix.fixedCount;
|
|
1527
|
+
}
|
|
1528
|
+
if (fixers.prettier) {
|
|
1529
|
+
const prettierResult = await this.verifier.runPrettier(codeFiles);
|
|
1530
|
+
fixSummary.prettier = prettierResult.formattedCount;
|
|
1531
|
+
}
|
|
1532
|
+
if (fixers.eslint) {
|
|
1533
|
+
const lintFixAfterPrettier = await this.verifier.runLintFix(codeFiles);
|
|
1534
|
+
fixSummary.eslint += lintFixAfterPrettier.fixedCount;
|
|
1535
|
+
}
|
|
1536
|
+
return this.verifyAfterFixes(codeFiles, fixSummary);
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Read-only Phase 1: typecheck + lint check only (no AST / ESLint --fix / Prettier writes).
|
|
1540
|
+
*/
|
|
1541
|
+
async runCodeAnalysisCheck(codeFiles, fixSummary) {
|
|
1542
|
+
const typecheck = await this.verifier.runTypeCheck(codeFiles);
|
|
1543
|
+
if (!typecheck.passed) {
|
|
1544
|
+
return { passed: false, fixed: fixSummary, issues: typecheck.errors };
|
|
1545
|
+
}
|
|
1546
|
+
const customIssuesEarly = await this.runCustomRulesPhase(codeFiles);
|
|
1547
|
+
if (customIssuesEarly.length > 0) {
|
|
1548
|
+
return { passed: false, fixed: fixSummary, issues: customIssuesEarly };
|
|
1549
|
+
}
|
|
1550
|
+
if (!this.config.fixers.eslint) {
|
|
1551
|
+
return { passed: true, fixed: fixSummary, issues: [] };
|
|
1552
|
+
}
|
|
1553
|
+
const lintCheck = await this.verifier.runLintCheck(codeFiles);
|
|
1554
|
+
if (!lintCheck.passed) {
|
|
1555
|
+
return { passed: false, fixed: fixSummary, issues: lintCheck.errors };
|
|
1556
|
+
}
|
|
1557
|
+
return { passed: true, fixed: fixSummary, issues: [] };
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Backup every code file before any mutating step (AST, ESLint --fix, Prettier).
|
|
1561
|
+
* AST fixers call recordChange as well; first snapshot wins so we keep the pre-run content for rollback.
|
|
1562
|
+
*/
|
|
1563
|
+
ensureTransactionPrimedForMutablePhase(codeFiles, transaction) {
|
|
1564
|
+
for (const file of codeFiles) {
|
|
1565
|
+
transaction.recordChange(file);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Run AST fixers and update summary
|
|
1570
|
+
*/
|
|
1571
|
+
async runAstFixers(codeFiles, transaction, fixSummary) {
|
|
1572
|
+
const astFixes = await this.autoFixer.scanAndFix(codeFiles, transaction);
|
|
1573
|
+
fixSummary.curlyBraces = astFixes.curlyBraces;
|
|
1574
|
+
fixSummary.singleLineArrow = astFixes.singleLineArrow;
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Re-verify after all fixes
|
|
1578
|
+
*/
|
|
1579
|
+
async verifyAfterFixes(codeFiles, fixSummary) {
|
|
1580
|
+
const recheckType = await this.verifier.runTypeCheck(codeFiles);
|
|
1581
|
+
if (!recheckType.passed) {
|
|
1582
|
+
return { passed: false, fixed: fixSummary, issues: recheckType.errors };
|
|
1583
|
+
}
|
|
1584
|
+
const customIssues = await this.runCustomRulesPhase(codeFiles);
|
|
1585
|
+
if (customIssues.length > 0) {
|
|
1586
|
+
return { passed: false, fixed: fixSummary, issues: customIssues };
|
|
1587
|
+
}
|
|
1588
|
+
if (this.config.fixers.eslint) {
|
|
1589
|
+
const recheckLint = await this.verifier.runLintCheck(codeFiles);
|
|
1590
|
+
if (!recheckLint.passed) {
|
|
1591
|
+
return { passed: false, fixed: fixSummary, issues: recheckLint.errors };
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return { passed: true, fixed: fixSummary, issues: [] };
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
var isFileRelevantToPaths = (filePath, relevantFiles) => {
|
|
1598
|
+
const normalizedFile = path13.normalize(filePath);
|
|
1599
|
+
return relevantFiles.some((f) => {
|
|
1600
|
+
const normalizedF = path13.normalize(f);
|
|
1601
|
+
if (process.platform === "win32") {
|
|
1602
|
+
const fLower = normalizedF.toLowerCase();
|
|
1603
|
+
const fileLower = normalizedFile.toLowerCase();
|
|
1604
|
+
return fLower.endsWith(fileLower) || fileLower.endsWith(fLower);
|
|
1605
|
+
}
|
|
1606
|
+
return normalizedF.endsWith(normalizedFile) || normalizedFile.endsWith(normalizedF);
|
|
1607
|
+
});
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
// src/phases/Phase2Server.ts
|
|
1611
|
+
var MIN_HTTP_STATUS_CLIENT_ERROR = 400;
|
|
1612
|
+
var tryClassifyAxiosSonarError = (error) => {
|
|
1613
|
+
const isAxios = typeof axios.isAxiosError === "function" ? axios.isAxiosError(error) : false;
|
|
1614
|
+
if (!isAxios) return void 0;
|
|
1615
|
+
const ax = error;
|
|
1616
|
+
if (ax.code === "ECONNABORTED") return "SONAR_TIMEOUT";
|
|
1617
|
+
if (ax.code === "ECONNREFUSED" || ax.code === "ENOTFOUND" || ax.code === "ETIMEDOUT") return "SONAR_CONNECTION_FAILED";
|
|
1618
|
+
const status = ax.response?.status;
|
|
1619
|
+
if (status !== void 0 && status >= MIN_HTTP_STATUS_CLIENT_ERROR) return "SONAR_API_ERROR";
|
|
1620
|
+
return void 0;
|
|
1621
|
+
};
|
|
1622
|
+
var classifySonarFailure = (error) => {
|
|
1623
|
+
const fromAxios = tryClassifyAxiosSonarError(error);
|
|
1624
|
+
if (fromAxios !== void 0) return fromAxios;
|
|
1625
|
+
if (error instanceof Error) {
|
|
1626
|
+
const msg = error.message;
|
|
1627
|
+
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|ECONNABORTED/i.test(msg)) return "SONAR_CONNECTION_FAILED";
|
|
1628
|
+
if (/SonarQube analysis timeout/i.test(msg)) return "SONAR_TIMEOUT";
|
|
1629
|
+
if (/exited with code/i.test(msg)) return "SONAR_API_ERROR";
|
|
1630
|
+
}
|
|
1631
|
+
return "SONAR_API_ERROR";
|
|
1632
|
+
};
|
|
1633
|
+
var ALLOWED_SCANNERS = /* @__PURE__ */ new Set(["sonar-scanner", "npx"]);
|
|
1634
|
+
var validateScannerPath = (scannerPath) => {
|
|
1635
|
+
const commandName = scannerPath.split(/[/\\]/).pop() ?? scannerPath;
|
|
1636
|
+
if (ALLOWED_SCANNERS.has(commandName)) return;
|
|
1637
|
+
throw new Error(`Security: Scanner '${commandName}' is not in the allowed list`);
|
|
1638
|
+
};
|
|
1639
|
+
var Phase2Server = class {
|
|
1640
|
+
config;
|
|
1641
|
+
constructor(config) {
|
|
1642
|
+
this.config = config;
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Run Phase 2 SonarQube analysis
|
|
1646
|
+
*/
|
|
1647
|
+
async run(files) {
|
|
1648
|
+
console.error("[Phase2] Starting SonarQube analysis");
|
|
1649
|
+
if (!this.isConfigured()) {
|
|
1650
|
+
console.error("[Phase2] Not configured, skipping");
|
|
1651
|
+
return { passed: true, issues: [] };
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
console.error("[Phase2] Running sonar-scanner");
|
|
1655
|
+
await this.runScanner();
|
|
1656
|
+
console.error("[Phase2] Waiting for analysis");
|
|
1657
|
+
await this.waitForAnalysis();
|
|
1658
|
+
console.error("[Phase2] Fetching issues");
|
|
1659
|
+
const issues = await this.fetchIssues(files);
|
|
1660
|
+
console.error(`[Phase2] Found ${issues.length} issues`);
|
|
1661
|
+
return {
|
|
1662
|
+
passed: issues.length === 0,
|
|
1663
|
+
issues
|
|
1664
|
+
};
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1667
|
+
const code = classifySonarFailure(error);
|
|
1668
|
+
console.error("[Phase2] Error:", message);
|
|
1669
|
+
return {
|
|
1670
|
+
passed: false,
|
|
1671
|
+
issues: [
|
|
1672
|
+
{
|
|
1673
|
+
rule: "sonarqube",
|
|
1674
|
+
file: "",
|
|
1675
|
+
line: 0,
|
|
1676
|
+
message: `SonarQube analysis failed: ${message}`,
|
|
1677
|
+
severity: SEVERITY.ERROR
|
|
1678
|
+
}
|
|
1679
|
+
],
|
|
1680
|
+
phaseError: { code, message }
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Check if Phase 2 is configured
|
|
1686
|
+
*/
|
|
1687
|
+
isConfigured() {
|
|
1688
|
+
return Boolean(this.config.sonarHostUrl && this.config.sonarToken && this.config.sonarProjectKey);
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Run sonar-scanner CLI
|
|
1692
|
+
*/
|
|
1693
|
+
runScanner() {
|
|
1694
|
+
const scannerPath = this.config.sonarScannerPath ?? "sonar-scanner";
|
|
1695
|
+
validateScannerPath(scannerPath);
|
|
1696
|
+
const hostUrl = this.config.sonarHostUrl ?? "";
|
|
1697
|
+
const token = this.config.sonarToken ?? "";
|
|
1698
|
+
const projectKey = this.config.sonarProjectKey ?? "";
|
|
1699
|
+
const args = [
|
|
1700
|
+
`-Dsonar.host.url=${hostUrl}`,
|
|
1701
|
+
`-Dsonar.token=${token}`,
|
|
1702
|
+
`-Dsonar.projectKey=${projectKey}`,
|
|
1703
|
+
// Skip Quality Gate wait - we handle it ourselves via API
|
|
1704
|
+
`-Dsonar.qualitygate.wait=false`,
|
|
1705
|
+
// Disable SCM to analyze working directory (not just committed code)
|
|
1706
|
+
// This allows catching issues BEFORE commit
|
|
1707
|
+
`-Dsonar.scm.disabled=true`
|
|
1708
|
+
];
|
|
1709
|
+
return new Promise((resolve, reject) => {
|
|
1710
|
+
const proc = spawn(scannerPath, args, {
|
|
1711
|
+
cwd: this.config.projectRoot,
|
|
1712
|
+
shell: true,
|
|
1713
|
+
timeout: this.config.phase2Timeout
|
|
1714
|
+
});
|
|
1715
|
+
let stdout = "";
|
|
1716
|
+
let stderr = "";
|
|
1717
|
+
proc.stdout.on("data", (data) => stdout += data.toString());
|
|
1718
|
+
proc.stderr.on("data", (data) => stderr += data.toString());
|
|
1719
|
+
proc.on("close", (code) => {
|
|
1720
|
+
if (code === 0) resolve();
|
|
1721
|
+
else reject(new Error(`sonar-scanner exited with code ${String(code ?? "unknown")}: ${stderr || stdout}`));
|
|
1722
|
+
});
|
|
1723
|
+
proc.on("error", (error) => reject(error));
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Wait for SonarQube analysis to complete
|
|
1728
|
+
* Uses polling with issues API as CE API may require admin permissions
|
|
1729
|
+
*/
|
|
1730
|
+
async waitForAnalysis() {
|
|
1731
|
+
const maxWait = 6e4;
|
|
1732
|
+
const fallbackWait = 15e3;
|
|
1733
|
+
const projectDoesNotExistWaitTime = 1e4;
|
|
1734
|
+
const pollInterval = 3e3;
|
|
1735
|
+
const startTime = Date.now();
|
|
1736
|
+
const ceApiWorks = await this.checkCeApiAccess();
|
|
1737
|
+
if (ceApiWorks) {
|
|
1738
|
+
while (Date.now() - startTime < maxWait) {
|
|
1739
|
+
const status = await this.getTaskStatus();
|
|
1740
|
+
if (status === SONAR_TASK_STATUS.SUCCESS) return;
|
|
1741
|
+
if (status === SONAR_TASK_STATUS.FAILED || status === SONAR_TASK_STATUS.CANCELED) {
|
|
1742
|
+
throw new Error(`SonarQube analysis ${status.toLowerCase()}`);
|
|
1743
|
+
}
|
|
1744
|
+
await this.sleep(pollInterval);
|
|
1745
|
+
}
|
|
1746
|
+
} else {
|
|
1747
|
+
await this.sleep(fallbackWait);
|
|
1748
|
+
const projectExists = await this.checkProjectAnalyzed();
|
|
1749
|
+
if (!projectExists) await this.sleep(projectDoesNotExistWaitTime);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
throw new Error("SonarQube analysis timeout");
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Check if status is OK
|
|
1756
|
+
*/
|
|
1757
|
+
isStatusOk(status) {
|
|
1758
|
+
const statusOkCode = 200;
|
|
1759
|
+
return status === statusOkCode;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Check if CE API is accessible
|
|
1763
|
+
*/
|
|
1764
|
+
async checkCeApiAccess() {
|
|
1765
|
+
const hostUrl = this.config.sonarHostUrl ?? "";
|
|
1766
|
+
const token = this.config.sonarToken ?? "";
|
|
1767
|
+
const timeout = 5e3;
|
|
1768
|
+
try {
|
|
1769
|
+
const response = await axios.get(`${hostUrl}/api/ce/activity`, {
|
|
1770
|
+
params: { ps: 1 },
|
|
1771
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1772
|
+
timeout
|
|
1773
|
+
});
|
|
1774
|
+
return this.isStatusOk(response.status);
|
|
1775
|
+
} catch {
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Check if project has been analyzed (has measures)
|
|
1781
|
+
*/
|
|
1782
|
+
async checkProjectAnalyzed() {
|
|
1783
|
+
const hostUrl = this.config.sonarHostUrl ?? "";
|
|
1784
|
+
const token = this.config.sonarToken ?? "";
|
|
1785
|
+
const component = this.config.sonarProjectKey ?? "";
|
|
1786
|
+
const timeout = 5e3;
|
|
1787
|
+
const metricKeys = "ncloc";
|
|
1788
|
+
try {
|
|
1789
|
+
const response = await axios.get(`${hostUrl}/api/measures/component`, {
|
|
1790
|
+
params: {
|
|
1791
|
+
component,
|
|
1792
|
+
metricKeys
|
|
1793
|
+
},
|
|
1794
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1795
|
+
timeout
|
|
1796
|
+
});
|
|
1797
|
+
return this.isStatusOk(response.status);
|
|
1798
|
+
} catch {
|
|
1799
|
+
return false;
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Get latest analysis task status
|
|
1804
|
+
*/
|
|
1805
|
+
async getTaskStatus() {
|
|
1806
|
+
const hostUrl = this.config.sonarHostUrl ?? "";
|
|
1807
|
+
const token = this.config.sonarToken ?? "";
|
|
1808
|
+
const projectKey = this.config.sonarProjectKey ?? "";
|
|
1809
|
+
const timeout = 1e4;
|
|
1810
|
+
try {
|
|
1811
|
+
const response = await axios.get(`${hostUrl}/api/ce/activity`, {
|
|
1812
|
+
params: {
|
|
1813
|
+
component: projectKey,
|
|
1814
|
+
ps: 1
|
|
1815
|
+
},
|
|
1816
|
+
headers: {
|
|
1817
|
+
Authorization: `Bearer ${token}`
|
|
1818
|
+
},
|
|
1819
|
+
timeout
|
|
1820
|
+
});
|
|
1821
|
+
const tasks = response.data.tasks ?? [];
|
|
1822
|
+
const firstTask = tasks[0];
|
|
1823
|
+
if (firstTask) return firstTask.status ?? SONAR_TASK_STATUS.PENDING;
|
|
1824
|
+
return SONAR_TASK_STATUS.PENDING;
|
|
1825
|
+
} catch {
|
|
1826
|
+
return SONAR_TASK_STATUS.PENDING;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Fetch issues from SonarQube API
|
|
1831
|
+
*/
|
|
1832
|
+
async fetchIssues(files) {
|
|
1833
|
+
const hostUrl = this.config.sonarHostUrl ?? "";
|
|
1834
|
+
const token = this.config.sonarToken ?? "";
|
|
1835
|
+
const projectKey = this.config.sonarProjectKey ?? "";
|
|
1836
|
+
const timeout = 3e4;
|
|
1837
|
+
try {
|
|
1838
|
+
const response = await axios.get(`${hostUrl}/api/issues/search`, {
|
|
1839
|
+
params: {
|
|
1840
|
+
componentKeys: projectKey,
|
|
1841
|
+
resolved: "false",
|
|
1842
|
+
ps: 500
|
|
1843
|
+
},
|
|
1844
|
+
headers: {
|
|
1845
|
+
Authorization: `Bearer ${token}`
|
|
1846
|
+
},
|
|
1847
|
+
timeout
|
|
1848
|
+
});
|
|
1849
|
+
const sonarIssues = response.data.issues ?? [];
|
|
1850
|
+
return sonarIssues.filter((issue) => this.isRelevantFile(issue.component, files)).map((issue) => this.convertIssue(issue));
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
console.error("[Phase2] Failed to fetch issues:", error);
|
|
1853
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1854
|
+
throw new Error(`SonarQube issues API failed: ${message}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Check if issue's component matches any of the relevant files
|
|
1859
|
+
*/
|
|
1860
|
+
isRelevantFile(component, files) {
|
|
1861
|
+
const colonIndex = component.indexOf(":");
|
|
1862
|
+
const rawPath = colonIndex === -1 ? component : component.slice(colonIndex + 1);
|
|
1863
|
+
const sonarPath = path13.normalize(rawPath);
|
|
1864
|
+
return isFileRelevantToPaths(sonarPath, files);
|
|
1865
|
+
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Convert SonarQube issue to our Issue type
|
|
1868
|
+
*/
|
|
1869
|
+
convertIssue(sonarIssue) {
|
|
1870
|
+
const colonIndex = sonarIssue.component.indexOf(":");
|
|
1871
|
+
const file = colonIndex === -1 ? sonarIssue.component : sonarIssue.component.slice(colonIndex + 1);
|
|
1872
|
+
return {
|
|
1873
|
+
rule: sonarIssue.rule,
|
|
1874
|
+
file,
|
|
1875
|
+
line: sonarIssue.line ?? 0,
|
|
1876
|
+
message: sonarIssue.message,
|
|
1877
|
+
severity: this.mapSeverity(sonarIssue.severity)
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Map SonarQube severity to our severity
|
|
1882
|
+
*/
|
|
1883
|
+
mapSeverity(sonarSeverity) {
|
|
1884
|
+
const upperSeverity = sonarSeverity.toUpperCase();
|
|
1885
|
+
if (upperSeverity === SONAR_SEVERITY.BLOCKER || upperSeverity === SONAR_SEVERITY.CRITICAL) return SEVERITY.ERROR;
|
|
1886
|
+
if (upperSeverity === SONAR_SEVERITY.MAJOR) return SEVERITY.WARNING;
|
|
1887
|
+
return SEVERITY.INFO;
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Sleep utility
|
|
1891
|
+
*/
|
|
1892
|
+
sleep(ms) {
|
|
1893
|
+
return setTimeout(ms);
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
// src/core/QualityGate.ts
|
|
1898
|
+
var QualityGate = class {
|
|
1899
|
+
config;
|
|
1900
|
+
transactionManager;
|
|
1901
|
+
phase1;
|
|
1902
|
+
phase2;
|
|
1903
|
+
constructor(config, deps) {
|
|
1904
|
+
this.config = config;
|
|
1905
|
+
this.transactionManager = deps?.transactionManager ?? new TransactionManager();
|
|
1906
|
+
this.phase1 = deps?.phase1 ?? new Phase1Local(config);
|
|
1907
|
+
this.phase2 = deps?.phase2 ?? new Phase2Server(config);
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Run quality gate on specified files
|
|
1911
|
+
* @param options.phases `phase1` | `phase2` | `all` (default). `phase1Mode` defaults to `fix`.
|
|
1912
|
+
*/
|
|
1913
|
+
async run(files, options) {
|
|
1914
|
+
const ctx = this.createContext(files);
|
|
1915
|
+
try {
|
|
1916
|
+
return await this.executePhases(ctx, options);
|
|
1917
|
+
} catch (error) {
|
|
1918
|
+
return this.handleError(error, ctx);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
createContext(files) {
|
|
1922
|
+
return {
|
|
1923
|
+
startTime: Date.now(),
|
|
1924
|
+
phase1Time: 0,
|
|
1925
|
+
phase2Time: 0,
|
|
1926
|
+
transaction: this.transactionManager.begin(),
|
|
1927
|
+
absoluteFiles: files.map((file) => path13.isAbsolute(file) ? file : path13.resolve(this.config.projectRoot, file))
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
executePhases(ctx, options) {
|
|
1931
|
+
const phases = options?.phases ?? "all";
|
|
1932
|
+
const phase1Mode = options?.phase1Mode ?? "fix";
|
|
1933
|
+
const phase1Opts = { phase1Mode };
|
|
1934
|
+
if (phases === "phase2") return this.runPhase2OnlyPipeline(ctx);
|
|
1935
|
+
return this.runPhase1Pipeline(ctx, phase1Opts, phases);
|
|
1936
|
+
}
|
|
1937
|
+
emptyPhaseResult(passed) {
|
|
1938
|
+
return {
|
|
1939
|
+
passed,
|
|
1940
|
+
fixed: this.createEmptyFixSummary(),
|
|
1941
|
+
issues: []
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Human-readable summary for MCP/CLI when Phase 1 fails (e.g. "TypeScript: 2 issues in src/index.ts").
|
|
1946
|
+
*/
|
|
1947
|
+
summarizePhase1Failure(issues) {
|
|
1948
|
+
if (issues.length === 0) return "Local analysis found issues. Fix these first.";
|
|
1949
|
+
const root = this.config.projectRoot;
|
|
1950
|
+
const segments = [];
|
|
1951
|
+
const tsIssues = issues.filter((i) => this.isPhase1TypeScriptIssue(i));
|
|
1952
|
+
const customIssues = issues.filter((i) => i.rule.startsWith(CUSTOM_RULE_PREFIX));
|
|
1953
|
+
const jsonIssues = issues.filter((i) => i.rule.startsWith("json/"));
|
|
1954
|
+
const eslintIssues = issues.filter(
|
|
1955
|
+
(i) => !this.isPhase1TypeScriptIssue(i) && !i.rule.startsWith(CUSTOM_RULE_PREFIX) && !i.rule.startsWith("json/")
|
|
1956
|
+
);
|
|
1957
|
+
if (tsIssues.length > 0) segments.push(this.formatPhase1IssueGroup("TypeScript", tsIssues, root));
|
|
1958
|
+
if (eslintIssues.length > 0) segments.push(this.formatPhase1IssueGroup("ESLint", eslintIssues, root));
|
|
1959
|
+
if (customIssues.length > 0) segments.push(this.formatPhase1IssueGroup("Custom rules", customIssues, root));
|
|
1960
|
+
if (jsonIssues.length > 0) segments.push(this.formatPhase1IssueGroup("JSON", jsonIssues, root));
|
|
1961
|
+
return segments.length > 0 ? segments.join("; ") : "Local analysis found issues. Fix these first.";
|
|
1962
|
+
}
|
|
1963
|
+
isPhase1TypeScriptIssue(issue) {
|
|
1964
|
+
return issue.rule === RULE_NAMES.TYPESCRIPT || issue.rule === RULE_NAMES.TYPESCRIPT_DEPRECATED;
|
|
1965
|
+
}
|
|
1966
|
+
formatPhase1IssueGroup(label, issues, projectRoot) {
|
|
1967
|
+
const n = issues.length;
|
|
1968
|
+
const issueWord = n === 1 ? "issue" : "issues";
|
|
1969
|
+
const first = issues[0];
|
|
1970
|
+
const file = first?.file;
|
|
1971
|
+
let where;
|
|
1972
|
+
if (file === void 0 || file === "unknown" || file.length === 0) {
|
|
1973
|
+
where = "multiple files";
|
|
1974
|
+
} else {
|
|
1975
|
+
const resolved = path13.isAbsolute(file) ? file : path13.resolve(projectRoot, file);
|
|
1976
|
+
const rel = path13.relative(projectRoot, resolved);
|
|
1977
|
+
where = rel.length > 0 && !rel.startsWith("..") ? rel : file;
|
|
1978
|
+
}
|
|
1979
|
+
return `${label}: ${n} ${issueWord} in ${where}`;
|
|
1980
|
+
}
|
|
1981
|
+
async runPhase2OnlyPipeline(ctx) {
|
|
1982
|
+
if (!this.phase2.isConfigured()) {
|
|
1983
|
+
return this.buildConfigurationFailureResponse(ctx, "Phase 2 is not configured (SonarQube env or config missing).");
|
|
1984
|
+
}
|
|
1985
|
+
const phase2Start = Date.now();
|
|
1986
|
+
const serverResult = await this.phase2.run(ctx.absoluteFiles);
|
|
1987
|
+
ctx.phase2Time = Date.now() - phase2Start;
|
|
1988
|
+
if (!serverResult.passed) {
|
|
1989
|
+
const rollbackResponse = await this.tryRollback(ctx);
|
|
1990
|
+
if (rollbackResponse) return rollbackResponse;
|
|
1991
|
+
return this.buildFailResponse(
|
|
1992
|
+
ctx,
|
|
1993
|
+
PHASE.SERVER,
|
|
1994
|
+
this.phase2FailureMessage(serverResult),
|
|
1995
|
+
this.emptyPhaseResult(false),
|
|
1996
|
+
this.phase2FailResponseOptions(serverResult)
|
|
1997
|
+
);
|
|
1998
|
+
}
|
|
1999
|
+
await ctx.transaction.commit();
|
|
2000
|
+
return this.buildSuccessResponse(
|
|
2001
|
+
ctx,
|
|
2002
|
+
PHASE.COMPLETE,
|
|
2003
|
+
"\u2705 Phase 2 (SonarQube) checks passed.",
|
|
2004
|
+
this.emptyPhaseResult(true)
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
async runPhase1Pipeline(ctx, phase1Opts, phases) {
|
|
2008
|
+
const phase1Start = Date.now();
|
|
2009
|
+
const localResult = await this.phase1.run(ctx.absoluteFiles, ctx.transaction, phase1Opts);
|
|
2010
|
+
ctx.phase1Time = Date.now() - phase1Start;
|
|
2011
|
+
if (!localResult.passed) {
|
|
2012
|
+
const rollbackResponse = await this.tryRollback(ctx);
|
|
2013
|
+
if (rollbackResponse) return rollbackResponse;
|
|
2014
|
+
return this.buildFailResponse(ctx, PHASE.LOCAL, this.summarizePhase1Failure(localResult.issues), localResult);
|
|
2015
|
+
}
|
|
2016
|
+
if (phases === "phase1") {
|
|
2017
|
+
await ctx.transaction.commit();
|
|
2018
|
+
return this.buildSuccessResponse(ctx, PHASE.LOCAL, "\u2705 Phase 1 complete (Phase 2 skipped).", localResult);
|
|
2019
|
+
}
|
|
2020
|
+
if (!this.phase2.isConfigured()) {
|
|
2021
|
+
await ctx.transaction.commit();
|
|
2022
|
+
return this.buildSuccessResponse(ctx, PHASE.LOCAL, "\u2705 Local checks passed (Phase 2 not configured)", localResult);
|
|
2023
|
+
}
|
|
2024
|
+
return this.runPhase2WithLocal(ctx, localResult);
|
|
2025
|
+
}
|
|
2026
|
+
async runPhase2WithLocal(ctx, localResult) {
|
|
2027
|
+
const phase2Start = Date.now();
|
|
2028
|
+
const serverResult = await this.phase2.run(ctx.absoluteFiles);
|
|
2029
|
+
ctx.phase2Time = Date.now() - phase2Start;
|
|
2030
|
+
if (!serverResult.passed) {
|
|
2031
|
+
const rollbackResponse = await this.tryRollback(ctx);
|
|
2032
|
+
if (rollbackResponse) return rollbackResponse;
|
|
2033
|
+
return this.buildFailResponse(
|
|
2034
|
+
ctx,
|
|
2035
|
+
PHASE.SERVER,
|
|
2036
|
+
this.phase2FailureMessage(serverResult),
|
|
2037
|
+
localResult,
|
|
2038
|
+
this.phase2FailResponseOptions(serverResult)
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
await ctx.transaction.commit();
|
|
2042
|
+
return this.buildSuccessResponse(ctx, PHASE.COMPLETE, "\u2705 All quality checks passed!", localResult);
|
|
2043
|
+
}
|
|
2044
|
+
phase2FailureMessage(serverResult) {
|
|
2045
|
+
if (serverResult.phaseError !== void 0) return "Phase 2 (SonarQube) failed due to a server or network error.";
|
|
2046
|
+
return "SonarQube found additional issues.";
|
|
2047
|
+
}
|
|
2048
|
+
phase2FailResponseOptions(serverResult) {
|
|
2049
|
+
const qe = serverResult.phaseError;
|
|
2050
|
+
if (qe === void 0) return { remaining: serverResult.issues };
|
|
2051
|
+
return { remaining: serverResult.issues, qualityError: qe };
|
|
2052
|
+
}
|
|
2053
|
+
buildFailResponse(ctx, phase, message, result, options) {
|
|
2054
|
+
const remaining = options?.remaining ?? result.issues;
|
|
2055
|
+
const params = {
|
|
2056
|
+
phase,
|
|
2057
|
+
success: false,
|
|
2058
|
+
message,
|
|
2059
|
+
fixed: result.fixed,
|
|
2060
|
+
remaining,
|
|
2061
|
+
phase1Time: ctx.phase1Time,
|
|
2062
|
+
totalTime: Date.now() - ctx.startTime
|
|
2063
|
+
};
|
|
2064
|
+
if (ctx.phase2Time > 0) params.phase2Time = ctx.phase2Time;
|
|
2065
|
+
const response = this.buildResponse(params);
|
|
2066
|
+
if (options?.qualityError !== void 0) return { ...response, error: options.qualityError };
|
|
2067
|
+
return response;
|
|
2068
|
+
}
|
|
2069
|
+
buildSuccessResponse(ctx, phase, message, result) {
|
|
2070
|
+
const params = {
|
|
2071
|
+
phase,
|
|
2072
|
+
success: true,
|
|
2073
|
+
message,
|
|
2074
|
+
fixed: result.fixed,
|
|
2075
|
+
remaining: [],
|
|
2076
|
+
phase1Time: ctx.phase1Time,
|
|
2077
|
+
totalTime: Date.now() - ctx.startTime
|
|
2078
|
+
};
|
|
2079
|
+
if (ctx.phase2Time > 0) params.phase2Time = ctx.phase2Time;
|
|
2080
|
+
return this.buildResponse(params);
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Roll back file changes after a failed phase so disk matches pre-run state.
|
|
2084
|
+
* @returns Error response if rollback failed; otherwise `null` to continue building the failure response.
|
|
2085
|
+
*/
|
|
2086
|
+
async tryRollback(ctx) {
|
|
2087
|
+
try {
|
|
2088
|
+
await ctx.transaction.rollback();
|
|
2089
|
+
return null;
|
|
2090
|
+
} catch (rollbackError) {
|
|
2091
|
+
console.error("[QualityGate] Rollback failed after phase failure:", rollbackError);
|
|
2092
|
+
return this.buildErrorResponse(ctx, "ROLLBACK_FAILED", rollbackError);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
async handleError(error, ctx) {
|
|
2096
|
+
try {
|
|
2097
|
+
await ctx.transaction.rollback();
|
|
2098
|
+
} catch (rollbackError) {
|
|
2099
|
+
console.error("[QualityGate] Rollback failed:", rollbackError);
|
|
2100
|
+
return this.buildErrorResponse(ctx, "ROLLBACK_FAILED", rollbackError);
|
|
2101
|
+
}
|
|
2102
|
+
return this.buildErrorResponse(ctx, "UNEXPECTED_ERROR", error);
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Create empty FixSummary with all properties set to 0
|
|
2106
|
+
* Used for error responses when no fixes were applied
|
|
2107
|
+
*/
|
|
2108
|
+
createEmptyFixSummary() {
|
|
2109
|
+
return {
|
|
2110
|
+
eslint: 0,
|
|
2111
|
+
curlyBraces: 0,
|
|
2112
|
+
singleLineArrow: 0,
|
|
2113
|
+
prettier: 0,
|
|
2114
|
+
json: 0
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
buildErrorResponse(ctx, code, error) {
|
|
2118
|
+
const emptyFixed = this.createEmptyFixSummary();
|
|
2119
|
+
return {
|
|
2120
|
+
phase: PHASE.LOCAL,
|
|
2121
|
+
success: false,
|
|
2122
|
+
message: code === "ROLLBACK_FAILED" ? "Critical error: rollback failed" : "An error occurred during quality checks",
|
|
2123
|
+
fixed: emptyFixed,
|
|
2124
|
+
remaining: [],
|
|
2125
|
+
timing: {
|
|
2126
|
+
phase1: this.formatTime(ctx.phase1Time),
|
|
2127
|
+
total: this.formatTime(Date.now() - ctx.startTime)
|
|
2128
|
+
},
|
|
2129
|
+
totalIssues: 0,
|
|
2130
|
+
remainingCount: 0,
|
|
2131
|
+
fixedCount: 0,
|
|
2132
|
+
error: {
|
|
2133
|
+
code,
|
|
2134
|
+
message: error instanceof Error ? error.message : String(error)
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
/** Configuration / prerequisite failure (e.g. Phase 2 requested but Sonar not configured) */
|
|
2139
|
+
buildConfigurationFailureResponse(ctx, message) {
|
|
2140
|
+
const emptyFixed = this.createEmptyFixSummary();
|
|
2141
|
+
return {
|
|
2142
|
+
phase: PHASE.LOCAL,
|
|
2143
|
+
success: false,
|
|
2144
|
+
message,
|
|
2145
|
+
fixed: emptyFixed,
|
|
2146
|
+
remaining: [],
|
|
2147
|
+
timing: {
|
|
2148
|
+
phase1: this.formatTime(ctx.phase1Time),
|
|
2149
|
+
total: this.formatTime(Date.now() - ctx.startTime)
|
|
2150
|
+
},
|
|
2151
|
+
totalIssues: 0,
|
|
2152
|
+
remainingCount: 0,
|
|
2153
|
+
fixedCount: 0,
|
|
2154
|
+
error: {
|
|
2155
|
+
code: "CONFIG_INVALID",
|
|
2156
|
+
message
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Calculate total fixed count by summing all numeric values in FixSummary
|
|
2162
|
+
* Dynamic approach - automatically includes new fix types without code changes
|
|
2163
|
+
*/
|
|
2164
|
+
calculateFixedCount(fixed) {
|
|
2165
|
+
const values = Object.values(fixed);
|
|
2166
|
+
return values.reduce((sum, count) => sum + count, 0);
|
|
2167
|
+
}
|
|
2168
|
+
buildResponse(params) {
|
|
2169
|
+
const fixedCount = this.calculateFixedCount(params.fixed);
|
|
2170
|
+
const remainingCount = params.remaining.length;
|
|
2171
|
+
const totalIssues = fixedCount + remainingCount;
|
|
2172
|
+
const response = {
|
|
2173
|
+
phase: params.phase,
|
|
2174
|
+
success: params.success,
|
|
2175
|
+
message: params.message,
|
|
2176
|
+
totalIssues,
|
|
2177
|
+
remainingCount,
|
|
2178
|
+
fixedCount,
|
|
2179
|
+
fixed: params.fixed,
|
|
2180
|
+
remaining: params.remaining,
|
|
2181
|
+
timing: {
|
|
2182
|
+
phase1: this.formatTime(params.phase1Time),
|
|
2183
|
+
total: this.formatTime(params.totalTime)
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
if (params.phase2Time) response.timing.phase2 = this.formatTime(params.phase2Time);
|
|
2187
|
+
return response;
|
|
2188
|
+
}
|
|
2189
|
+
formatTime(ms) {
|
|
2190
|
+
return ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
var normalizePath = (p) => {
|
|
2194
|
+
const normalized = path13.normalize(p);
|
|
2195
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
2196
|
+
};
|
|
2197
|
+
var isPathUnder = (childPath, parentPath) => {
|
|
2198
|
+
const normalizedChild = normalizePath(childPath);
|
|
2199
|
+
const normalizedParent = normalizePath(parentPath);
|
|
2200
|
+
const parentWithSep = normalizedParent.endsWith(path13.sep) ? normalizedParent : `${normalizedParent}${path13.sep}`;
|
|
2201
|
+
return normalizedChild.startsWith(parentWithSep) || normalizedChild === normalizedParent;
|
|
2202
|
+
};
|
|
2203
|
+
var findTsConfig = (filePath, projectRoot) => {
|
|
2204
|
+
const absoluteFile = path13.resolve(filePath);
|
|
2205
|
+
const absoluteRoot = path13.resolve(projectRoot);
|
|
2206
|
+
let currentDir = path13.dirname(absoluteFile);
|
|
2207
|
+
while (isPathUnder(currentDir, absoluteRoot)) {
|
|
2208
|
+
const tsConfigPath = path13.join(currentDir, "tsconfig.json");
|
|
2209
|
+
if (fs8__default.existsSync(tsConfigPath)) return tsConfigPath;
|
|
2210
|
+
const parentDir = path13.dirname(currentDir);
|
|
2211
|
+
if (parentDir === currentDir) break;
|
|
2212
|
+
currentDir = parentDir;
|
|
2213
|
+
}
|
|
2214
|
+
const rootTsConfig = path13.join(absoluteRoot, "tsconfig.json");
|
|
2215
|
+
if (fs8__default.existsSync(rootTsConfig)) return rootTsConfig;
|
|
2216
|
+
throw new Error(`tsconfig.json not found for ${filePath}`);
|
|
2217
|
+
};
|
|
2218
|
+
var groupFilesByTsConfig = (files, projectRoot) => {
|
|
2219
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2220
|
+
for (const file of files) {
|
|
2221
|
+
try {
|
|
2222
|
+
const tsConfig = findTsConfig(file, projectRoot);
|
|
2223
|
+
const existing = groups.get(tsConfig) ?? [];
|
|
2224
|
+
existing.push(file);
|
|
2225
|
+
groups.set(tsConfig, existing);
|
|
2226
|
+
} catch {
|
|
2227
|
+
const noTsConfig = "__no_tsconfig__";
|
|
2228
|
+
const existing = groups.get(noTsConfig) ?? [];
|
|
2229
|
+
existing.push(file);
|
|
2230
|
+
groups.set(noTsConfig, existing);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return groups;
|
|
2234
|
+
};
|
|
2235
|
+
var ESLINT_CONFIG_REL = path13.join("eslint", "config.mjs");
|
|
2236
|
+
var EMBEDDED_ESLINT_CONFIG_MAX_WALK_DEPTH = 8;
|
|
2237
|
+
var walkUpForEslintConfig = (startDir) => {
|
|
2238
|
+
let current = path13.resolve(startDir);
|
|
2239
|
+
for (let depth = 0; depth < EMBEDDED_ESLINT_CONFIG_MAX_WALK_DEPTH; depth += 1) {
|
|
2240
|
+
const candidate = path13.join(current, ESLINT_CONFIG_REL);
|
|
2241
|
+
if (fs8__default.existsSync(candidate)) return candidate;
|
|
2242
|
+
const parent = path13.dirname(current);
|
|
2243
|
+
if (parent === current) break;
|
|
2244
|
+
current = parent;
|
|
2245
|
+
}
|
|
2246
|
+
return void 0;
|
|
2247
|
+
};
|
|
2248
|
+
var resolveEmbeddedEslintConfigPath = () => {
|
|
2249
|
+
const moduleDir = path13.dirname(fileURLToPath(import.meta.url));
|
|
2250
|
+
const found = walkUpForEslintConfig(moduleDir);
|
|
2251
|
+
if (found) return found;
|
|
2252
|
+
return path13.resolve(moduleDir, ESLINT_CONFIG_REL);
|
|
2253
|
+
};
|
|
2254
|
+
var findProjectEslintConfigFile = (projectRoot) => {
|
|
2255
|
+
const root = path13.resolve(projectRoot);
|
|
2256
|
+
for (const name of ESLINT_PROJECT_ROOT_CONFIG_FILENAMES) {
|
|
2257
|
+
const full = path13.join(root, name);
|
|
2258
|
+
if (fs8__default.existsSync(full)) return full;
|
|
2259
|
+
}
|
|
2260
|
+
return void 0;
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
// src/core/Verifier.ts
|
|
2264
|
+
var ALLOWED_COMMANDS = /* @__PURE__ */ new Set(["npx", "eslint", "tsc", "yarn", "prettier"]);
|
|
2265
|
+
var validateCommand = (command) => {
|
|
2266
|
+
if (ALLOWED_COMMANDS.has(command)) return;
|
|
2267
|
+
if (command.includes("node_modules") && command.includes(".bin")) return;
|
|
2268
|
+
throw new Error(`Security: Command '${command}' is not in the allowed list`);
|
|
2269
|
+
};
|
|
2270
|
+
var findTscCommand = (projectDir) => {
|
|
2271
|
+
const localTsc = path13.join(projectDir, "node_modules", ".bin", "tsc");
|
|
2272
|
+
const localTscWin = `${localTsc}.cmd`;
|
|
2273
|
+
if (process.platform === "win32" && fs8.existsSync(localTscWin)) return { command: localTscWin, args: [] };
|
|
2274
|
+
if (fs8.existsSync(localTsc)) return { command: localTsc, args: [] };
|
|
2275
|
+
return { command: "yarn", args: ["tsc"] };
|
|
2276
|
+
};
|
|
2277
|
+
var findEslintCommand = (projectDir) => {
|
|
2278
|
+
const mcpPackageDir = path13.join(projectDir, "ai-quality-gate");
|
|
2279
|
+
const mcpEslint = path13.join(mcpPackageDir, "node_modules", ".bin", "eslint");
|
|
2280
|
+
const mcpEslintWin = `${mcpEslint}.cmd`;
|
|
2281
|
+
if (process.platform === "win32" && fs8.existsSync(mcpEslintWin)) return { command: mcpEslintWin, found: true };
|
|
2282
|
+
if (fs8.existsSync(mcpEslint)) return { command: mcpEslint, found: true };
|
|
2283
|
+
const projectEslint = path13.join(projectDir, "node_modules", ".bin", "eslint");
|
|
2284
|
+
const projectEslintWin = `${projectEslint}.cmd`;
|
|
2285
|
+
if (process.platform === "win32" && fs8.existsSync(projectEslintWin)) return { command: projectEslintWin, found: true };
|
|
2286
|
+
if (fs8.existsSync(projectEslint)) return { command: projectEslint, found: true };
|
|
2287
|
+
return { command: "npx", found: false };
|
|
2288
|
+
};
|
|
2289
|
+
var findPrettierCommand = (projectDir) => {
|
|
2290
|
+
const projectPrettier = path13.join(projectDir, "node_modules", ".bin", "prettier");
|
|
2291
|
+
const projectPrettierWin = `${projectPrettier}.cmd`;
|
|
2292
|
+
if (process.platform === "win32" && fs8.existsSync(projectPrettierWin)) {
|
|
2293
|
+
return { command: projectPrettierWin, found: true };
|
|
2294
|
+
}
|
|
2295
|
+
if (fs8.existsSync(projectPrettier)) return { command: projectPrettier, found: true };
|
|
2296
|
+
return { command: "npx", found: false };
|
|
2297
|
+
};
|
|
2298
|
+
var Verifier = class {
|
|
2299
|
+
config;
|
|
2300
|
+
embeddedEslintConfig;
|
|
2301
|
+
constructor(config) {
|
|
2302
|
+
this.config = config;
|
|
2303
|
+
this.embeddedEslintConfig = resolveEmbeddedEslintConfigPath();
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Prefer bundled strict config when present; otherwise root-level project ESLint config; else ESLint auto-discovery.
|
|
2307
|
+
*/
|
|
2308
|
+
resolveEslintCliConfigArgs() {
|
|
2309
|
+
if (fs8.existsSync(this.embeddedEslintConfig)) return ["--config", this.embeddedEslintConfig];
|
|
2310
|
+
const projectConfig = findProjectEslintConfigFile(this.config.projectRoot);
|
|
2311
|
+
if (projectConfig !== void 0) return ["--config", projectConfig];
|
|
2312
|
+
return [];
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Report files without tsconfig as errors
|
|
2316
|
+
*/
|
|
2317
|
+
reportMissingTsConfig(groupFiles) {
|
|
2318
|
+
return groupFiles.map((file) => ({
|
|
2319
|
+
rule: RULE_NAMES.TYPESCRIPT,
|
|
2320
|
+
file,
|
|
2321
|
+
line: 0,
|
|
2322
|
+
message: "No tsconfig.json found for this file",
|
|
2323
|
+
severity: SEVERITY.ERROR
|
|
2324
|
+
}));
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Run TypeScript typecheck for a single tsconfig group
|
|
2328
|
+
*/
|
|
2329
|
+
async runTypeCheckForGroup(tsConfigPath, groupFiles) {
|
|
2330
|
+
const projectDir = path13.dirname(tsConfigPath);
|
|
2331
|
+
try {
|
|
2332
|
+
const tscCmd = findTscCommand(projectDir);
|
|
2333
|
+
const result = await this.execCommand(
|
|
2334
|
+
tscCmd.command,
|
|
2335
|
+
[...tscCmd.args, "--noEmit", "--pretty", "false", "--project", tsConfigPath],
|
|
2336
|
+
{
|
|
2337
|
+
cwd: projectDir,
|
|
2338
|
+
timeout: this.config.phase1Timeout
|
|
2339
|
+
}
|
|
2340
|
+
);
|
|
2341
|
+
const output = result.stdout || result.stderr;
|
|
2342
|
+
if (!result.success || TYPESCRIPT_ERROR_PATTERN.test(output) || DEPRECATED_PATTERN.test(output)) {
|
|
2343
|
+
return this.parseTypeCheckErrors(output, groupFiles);
|
|
2344
|
+
}
|
|
2345
|
+
return [];
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2348
|
+
return [
|
|
2349
|
+
{
|
|
2350
|
+
rule: RULE_NAMES.TYPESCRIPT,
|
|
2351
|
+
file: tsConfigPath,
|
|
2352
|
+
line: 0,
|
|
2353
|
+
message: `TypeScript: typecheck could not run \u2014 ${detail}`,
|
|
2354
|
+
severity: SEVERITY.ERROR
|
|
2355
|
+
}
|
|
2356
|
+
];
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
/**
|
|
2360
|
+
* Run TypeScript type check on files
|
|
2361
|
+
* Groups files by tsconfig for accurate checking
|
|
2362
|
+
*/
|
|
2363
|
+
async runTypeCheck(files) {
|
|
2364
|
+
const tsFiles = files.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
|
2365
|
+
if (tsFiles.length === 0) return { passed: true, errors: [] };
|
|
2366
|
+
const groups = groupFilesByTsConfig(tsFiles, this.config.projectRoot);
|
|
2367
|
+
const allErrors = [];
|
|
2368
|
+
for (const [tsConfigPath, groupFiles] of groups) {
|
|
2369
|
+
if (tsConfigPath === "__no_tsconfig__") {
|
|
2370
|
+
allErrors.push(...this.reportMissingTsConfig(groupFiles));
|
|
2371
|
+
continue;
|
|
2372
|
+
}
|
|
2373
|
+
const errors = await this.runTypeCheckForGroup(tsConfigPath, groupFiles);
|
|
2374
|
+
allErrors.push(...errors);
|
|
2375
|
+
}
|
|
2376
|
+
return {
|
|
2377
|
+
passed: allErrors.length === 0,
|
|
2378
|
+
errors: allErrors
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Run ESLint with embedded config and auto-fix
|
|
2383
|
+
*/
|
|
2384
|
+
runLintFix(files) {
|
|
2385
|
+
return this.runLint(files, true);
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Run lint without fix (for verification)
|
|
2389
|
+
*/
|
|
2390
|
+
runLintCheck(files) {
|
|
2391
|
+
return this.runLint(files, false);
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Run Prettier to format files after ESLint fix
|
|
2395
|
+
* This ensures consistent formatting (e.g., single-line if statements)
|
|
2396
|
+
* Runs Prettier from each file's app directory to pick up correct config
|
|
2397
|
+
*/
|
|
2398
|
+
async runPrettier(files) {
|
|
2399
|
+
const formattableFiles = files.filter((f) => /\.(?:ts|tsx|js|jsx|vue|json)$/.test(f));
|
|
2400
|
+
if (formattableFiles.length === 0) return { success: true, formattedCount: 0 };
|
|
2401
|
+
const filesByAppDir = this.groupFilesByAppDir(formattableFiles);
|
|
2402
|
+
let totalFormatted = 0;
|
|
2403
|
+
for (const [appDir, appFiles] of Object.entries(filesByAppDir)) {
|
|
2404
|
+
const prettierCmd = findPrettierCommand(appDir);
|
|
2405
|
+
const args = prettierCmd.found ? ["--write", ...appFiles] : ["prettier", "--write", ...appFiles];
|
|
2406
|
+
try {
|
|
2407
|
+
await this.execCommand(prettierCmd.command, args, {
|
|
2408
|
+
cwd: appDir,
|
|
2409
|
+
timeout: this.config.phase1Timeout
|
|
2410
|
+
});
|
|
2411
|
+
totalFormatted += appFiles.length;
|
|
2412
|
+
} catch {
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
return { success: totalFormatted > 0, formattedCount: totalFormatted };
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Group files by their app directory (api, admin, frontend, etc.)
|
|
2419
|
+
* Finds the nearest directory containing package.json
|
|
2420
|
+
*/
|
|
2421
|
+
groupFilesByAppDir(files) {
|
|
2422
|
+
const result = {};
|
|
2423
|
+
for (const file of files) {
|
|
2424
|
+
const appDir = this.findAppDir(file);
|
|
2425
|
+
result[appDir] ??= [];
|
|
2426
|
+
result[appDir].push(file);
|
|
2427
|
+
}
|
|
2428
|
+
return result;
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Find the app directory for a file (nearest parent with package.json)
|
|
2432
|
+
*/
|
|
2433
|
+
findAppDir(filePath) {
|
|
2434
|
+
let dir = path13.dirname(filePath);
|
|
2435
|
+
while (dir !== path13.dirname(dir)) {
|
|
2436
|
+
if (fs8.existsSync(path13.join(dir, "package.json"))) return dir;
|
|
2437
|
+
dir = path13.dirname(dir);
|
|
2438
|
+
}
|
|
2439
|
+
return this.config.projectRoot;
|
|
2440
|
+
}
|
|
2441
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2442
|
+
// Private Methods
|
|
2443
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2444
|
+
/**
|
|
2445
|
+
* Core lint execution - DRY: shared between runLintFix and runLintCheck
|
|
2446
|
+
*/
|
|
2447
|
+
async runLint(files, autoFix) {
|
|
2448
|
+
const lintableFiles = files.filter((f) => isLintableFile(f));
|
|
2449
|
+
if (lintableFiles.length === 0) return { passed: true, hasErrors: false, fixedCount: 0, errors: [] };
|
|
2450
|
+
const configArg = this.resolveEslintCliConfigArgs();
|
|
2451
|
+
const eslintCmd = findEslintCommand(this.config.projectRoot);
|
|
2452
|
+
const fixArg = autoFix ? ["--fix"] : [];
|
|
2453
|
+
const args = eslintCmd.found ? [...configArg, ...fixArg, "--format", "json", ...lintableFiles] : ["eslint", ...configArg, ...fixArg, "--format", "json", ...lintableFiles];
|
|
2454
|
+
try {
|
|
2455
|
+
const result = await this.execCommand(eslintCmd.command, args, {
|
|
2456
|
+
cwd: this.config.projectRoot,
|
|
2457
|
+
timeout: this.config.phase1Timeout
|
|
2458
|
+
});
|
|
2459
|
+
const output = result.stdout || "[]";
|
|
2460
|
+
return this.parseLintResult(output, lintableFiles);
|
|
2461
|
+
} catch (error) {
|
|
2462
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2463
|
+
return {
|
|
2464
|
+
passed: false,
|
|
2465
|
+
hasErrors: true,
|
|
2466
|
+
fixedCount: 0,
|
|
2467
|
+
errors: [
|
|
2468
|
+
{
|
|
2469
|
+
rule: RULE_NAMES.ESLINT,
|
|
2470
|
+
file: this.config.projectRoot,
|
|
2471
|
+
line: 0,
|
|
2472
|
+
message: `ESLint: command failed \u2014 ${detail}`,
|
|
2473
|
+
severity: SEVERITY.ERROR
|
|
2474
|
+
}
|
|
2475
|
+
]
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
execCommand(command, args, options) {
|
|
2480
|
+
validateCommand(command);
|
|
2481
|
+
return new Promise((resolve) => {
|
|
2482
|
+
const proc = spawn(command, args, {
|
|
2483
|
+
cwd: options.cwd,
|
|
2484
|
+
shell: true,
|
|
2485
|
+
timeout: options.timeout
|
|
2486
|
+
});
|
|
2487
|
+
let stdout = "";
|
|
2488
|
+
let stderr = "";
|
|
2489
|
+
proc.stdout.on("data", (data) => stdout += data.toString());
|
|
2490
|
+
proc.stderr.on("data", (data) => stderr += data.toString());
|
|
2491
|
+
proc.on(
|
|
2492
|
+
"close",
|
|
2493
|
+
(code) => resolve({
|
|
2494
|
+
success: code === 0,
|
|
2495
|
+
stdout,
|
|
2496
|
+
stderr
|
|
2497
|
+
})
|
|
2498
|
+
);
|
|
2499
|
+
proc.on(
|
|
2500
|
+
"error",
|
|
2501
|
+
(error) => resolve({
|
|
2502
|
+
success: false,
|
|
2503
|
+
stdout,
|
|
2504
|
+
stderr: error.message
|
|
2505
|
+
})
|
|
2506
|
+
);
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Parse a single TypeScript error line into an Issue
|
|
2511
|
+
*/
|
|
2512
|
+
parseErrorLine(line, relevantFiles) {
|
|
2513
|
+
const trimmedLine = line.trim();
|
|
2514
|
+
const errorRegex = /^([^(]+)\((\d+),(\d+)\): error TS\d+: (.+)$/;
|
|
2515
|
+
const match = errorRegex.exec(trimmedLine);
|
|
2516
|
+
if (!match) return null;
|
|
2517
|
+
const [, file, lineNum, col, message] = match;
|
|
2518
|
+
if (!file || !lineNum || !message) return null;
|
|
2519
|
+
const normalizedFile = path13.normalize(file);
|
|
2520
|
+
if (!isFileRelevantToPaths(normalizedFile, relevantFiles)) return null;
|
|
2521
|
+
const trimmedMessage = message.trim();
|
|
2522
|
+
const isDeprecated = DEPRECATED_PATTERN.test(trimmedMessage);
|
|
2523
|
+
return {
|
|
2524
|
+
rule: isDeprecated ? RULE_NAMES.TYPESCRIPT_DEPRECATED : RULE_NAMES.TYPESCRIPT,
|
|
2525
|
+
file: normalizedFile,
|
|
2526
|
+
line: Number.parseInt(lineNum, 10),
|
|
2527
|
+
column: col ? Number.parseInt(col, 10) : void 0,
|
|
2528
|
+
message: trimmedMessage,
|
|
2529
|
+
severity: isDeprecated ? SEVERITY.WARNING : SEVERITY.ERROR
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
parseTypeCheckErrors(output, relevantFiles) {
|
|
2533
|
+
const errors = [];
|
|
2534
|
+
const lines = output.split(/\r?\n/);
|
|
2535
|
+
for (const line of lines) {
|
|
2536
|
+
const issue = this.parseErrorLine(line, relevantFiles);
|
|
2537
|
+
if (issue) errors.push(issue);
|
|
2538
|
+
}
|
|
2539
|
+
return errors;
|
|
2540
|
+
}
|
|
2541
|
+
parseLintResult(jsonOutput, relevantFiles) {
|
|
2542
|
+
try {
|
|
2543
|
+
const results = JSON.parse(jsonOutput);
|
|
2544
|
+
return this.processLintResults(results, relevantFiles);
|
|
2545
|
+
} catch (parseError) {
|
|
2546
|
+
return this.createParseErrorResult(parseError);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
processLintResults(results, relevantFiles) {
|
|
2550
|
+
const errors = [];
|
|
2551
|
+
let fixedCount = 0;
|
|
2552
|
+
for (const result of results) {
|
|
2553
|
+
const isRelevant = isFileRelevantToPaths(result.filePath, relevantFiles);
|
|
2554
|
+
if (!isRelevant) continue;
|
|
2555
|
+
if (result.output !== void 0) fixedCount++;
|
|
2556
|
+
errors.push(...this.extractMessages(result));
|
|
2557
|
+
}
|
|
2558
|
+
return { passed: errors.length === 0, hasErrors: errors.length > 0, fixedCount, errors };
|
|
2559
|
+
}
|
|
2560
|
+
extractMessages(result) {
|
|
2561
|
+
return result.messages.filter((msg) => msg.severity >= 1).map((msg) => ({
|
|
2562
|
+
rule: msg.ruleId ?? RULE_NAMES.ESLINT,
|
|
2563
|
+
file: result.filePath,
|
|
2564
|
+
line: msg.line,
|
|
2565
|
+
column: msg.column,
|
|
2566
|
+
message: msg.message,
|
|
2567
|
+
severity: msg.severity === 2 ? SEVERITY.ERROR : SEVERITY.WARNING
|
|
2568
|
+
}));
|
|
2569
|
+
}
|
|
2570
|
+
createParseErrorResult(error) {
|
|
2571
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2572
|
+
return {
|
|
2573
|
+
passed: false,
|
|
2574
|
+
hasErrors: true,
|
|
2575
|
+
fixedCount: 0,
|
|
2576
|
+
errors: [
|
|
2577
|
+
{
|
|
2578
|
+
rule: RULE_NAMES.ESLINT,
|
|
2579
|
+
file: "unknown",
|
|
2580
|
+
line: 0,
|
|
2581
|
+
message: `ESLint: could not parse JSON output \u2014 ${detail}`,
|
|
2582
|
+
severity: SEVERITY.ERROR
|
|
2583
|
+
}
|
|
2584
|
+
]
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
};
|
|
2588
|
+
function filterCodeFiles(files) {
|
|
2589
|
+
const codeFiles = [];
|
|
2590
|
+
const skippedFiles = [];
|
|
2591
|
+
for (const file of files) {
|
|
2592
|
+
const ext = path13.extname(file).toLowerCase();
|
|
2593
|
+
if (SUPPORTED_CODE_EXTENSIONS.has(ext)) codeFiles.push(file);
|
|
2594
|
+
else skippedFiles.push(file);
|
|
2595
|
+
}
|
|
2596
|
+
return { codeFiles, skippedFiles };
|
|
2597
|
+
}
|
|
2598
|
+
function getPackageVersion() {
|
|
2599
|
+
const moduleDir = path13.dirname(fileURLToPath(import.meta.url));
|
|
2600
|
+
const candidates = [path13.join(moduleDir, "..", "package.json"), path13.join(moduleDir, "..", "..", "package.json")];
|
|
2601
|
+
for (const packageJsonPath of candidates) {
|
|
2602
|
+
try {
|
|
2603
|
+
const raw = fs8__default.readFileSync(packageJsonPath, "utf8");
|
|
2604
|
+
const parsed = JSON.parse(raw);
|
|
2605
|
+
if (typeof parsed.version === "string") return parsed.version;
|
|
2606
|
+
} catch {
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
return "0.0.0";
|
|
2610
|
+
}
|
|
2611
|
+
var SETUP_FIXER_VALUES = {
|
|
2612
|
+
CURLY_BRACES: "curlyBraces",
|
|
2613
|
+
ESLINT: "eslint",
|
|
2614
|
+
JSON_VALIDATOR: "jsonValidator",
|
|
2615
|
+
PRETTIER: "prettier",
|
|
2616
|
+
SINGLE_LINE_ARROW: "singleLineArrow"
|
|
2617
|
+
};
|
|
2618
|
+
function fixersFromSelection(selected) {
|
|
2619
|
+
const set = new Set(selected);
|
|
2620
|
+
return {
|
|
2621
|
+
curlyBraces: set.has(SETUP_FIXER_VALUES.CURLY_BRACES),
|
|
2622
|
+
eslint: set.has(SETUP_FIXER_VALUES.ESLINT),
|
|
2623
|
+
jsonValidator: set.has(SETUP_FIXER_VALUES.JSON_VALIDATOR),
|
|
2624
|
+
prettier: set.has(SETUP_FIXER_VALUES.PRETTIER),
|
|
2625
|
+
singleLineArrow: set.has(SETUP_FIXER_VALUES.SINGLE_LINE_ARROW)
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
function buildQualityGateYamlFromModel(model) {
|
|
2629
|
+
const doc = {
|
|
2630
|
+
enableI18nRules: model.enableI18nRules,
|
|
2631
|
+
fixers: model.fixers,
|
|
2632
|
+
phase1Timeout: model.phase1Timeout,
|
|
2633
|
+
phase2Timeout: model.phase2Timeout,
|
|
2634
|
+
projectRoot: model.projectRoot
|
|
2635
|
+
};
|
|
2636
|
+
if (model.sonar !== void 0) {
|
|
2637
|
+
doc.sonar = {
|
|
2638
|
+
hostUrl: model.sonar.hostUrl,
|
|
2639
|
+
projectKey: model.sonar.projectKey
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
const header = "# AI Quality Gate \u2014 generated by `ai-quality-gate --setup`\n# SonarQube: set SONAR_TOKEN in the environment (do not commit secrets).\n\n";
|
|
2643
|
+
return `${header}${stringify(doc, { lineWidth: 100 })}`;
|
|
2644
|
+
}
|
|
2645
|
+
function assertValidHttpUrl(value) {
|
|
2646
|
+
let parsed;
|
|
2647
|
+
try {
|
|
2648
|
+
parsed = new URL(value.trim());
|
|
2649
|
+
} catch {
|
|
2650
|
+
throw new TypeError(`Invalid URL: ${value}`);
|
|
2651
|
+
}
|
|
2652
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new TypeError("URL must use http: or https:");
|
|
2653
|
+
}
|
|
2654
|
+
function formatValidationError(error) {
|
|
2655
|
+
return error instanceof Error ? error.message : String(error);
|
|
2656
|
+
}
|
|
2657
|
+
function validateRequiredField(input, emptyMessage) {
|
|
2658
|
+
return input.trim().length > 0 ? true : emptyMessage;
|
|
2659
|
+
}
|
|
2660
|
+
function validateFixerKeysSelection(selected) {
|
|
2661
|
+
return Array.isArray(selected) && selected.length > 0 ? true : "Select at least one tool";
|
|
2662
|
+
}
|
|
2663
|
+
function validateSonarHostInput(input) {
|
|
2664
|
+
try {
|
|
2665
|
+
assertValidHttpUrl(input);
|
|
2666
|
+
return true;
|
|
2667
|
+
} catch (error) {
|
|
2668
|
+
return formatValidationError(error);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
function parsePositiveInt(raw, label) {
|
|
2672
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
2673
|
+
if (!Number.isFinite(n) || n <= 0) throw new TypeError(`${label} must be a positive integer`);
|
|
2674
|
+
return n;
|
|
2675
|
+
}
|
|
2676
|
+
async function promptSonarBlock() {
|
|
2677
|
+
const sonarAnswers = await inquirer.prompt([
|
|
2678
|
+
{
|
|
2679
|
+
message: "SonarQube host URL",
|
|
2680
|
+
name: "hostUrl",
|
|
2681
|
+
type: "input",
|
|
2682
|
+
validate: (input) => validateSonarHostInput(input)
|
|
2683
|
+
},
|
|
2684
|
+
{
|
|
2685
|
+
message: "SonarQube project key",
|
|
2686
|
+
name: "projectKey",
|
|
2687
|
+
type: "input",
|
|
2688
|
+
validate: (input) => validateRequiredField(input, "Project key is required")
|
|
2689
|
+
},
|
|
2690
|
+
{
|
|
2691
|
+
message: "SonarQube token (not saved to file). Optional \u2014 set SONAR_TOKEN in env; press Enter to skip",
|
|
2692
|
+
name: "_tokenHint",
|
|
2693
|
+
type: "password"
|
|
2694
|
+
}
|
|
2695
|
+
]);
|
|
2696
|
+
const tokenHint = sonarAnswers._tokenHint ?? "";
|
|
2697
|
+
if (tokenHint.length > 0) console.log('\nToken not written to disk. Example:\n export SONAR_TOKEN="<your-token>"\n');
|
|
2698
|
+
return {
|
|
2699
|
+
hostUrl: sonarAnswers.hostUrl.trim(),
|
|
2700
|
+
projectKey: sonarAnswers.projectKey.trim()
|
|
2701
|
+
};
|
|
2702
|
+
}
|
|
2703
|
+
function validateTimeoutInput(input, label) {
|
|
2704
|
+
try {
|
|
2705
|
+
parsePositiveInt(input, label);
|
|
2706
|
+
return true;
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
return formatValidationError(error);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
function promptFixerCheckbox() {
|
|
2712
|
+
return inquirer.prompt([
|
|
2713
|
+
{
|
|
2714
|
+
choices: [
|
|
2715
|
+
{ checked: true, name: "ESLint (auto-fix)", value: SETUP_FIXER_VALUES.ESLINT },
|
|
2716
|
+
{ checked: true, name: "Curly braces (AST)", value: SETUP_FIXER_VALUES.CURLY_BRACES },
|
|
2717
|
+
{ checked: true, name: "Single-line arrow (AST)", value: SETUP_FIXER_VALUES.SINGLE_LINE_ARROW },
|
|
2718
|
+
{ checked: true, name: "Prettier", value: SETUP_FIXER_VALUES.PRETTIER },
|
|
2719
|
+
{ checked: true, name: "JSON validator", value: SETUP_FIXER_VALUES.JSON_VALIDATOR }
|
|
2720
|
+
],
|
|
2721
|
+
message: "Which Phase 1 tools should run?",
|
|
2722
|
+
name: "fixerKeys",
|
|
2723
|
+
type: "checkbox",
|
|
2724
|
+
validate: (selected) => validateFixerKeysSelection(selected)
|
|
2725
|
+
}
|
|
2726
|
+
]);
|
|
2727
|
+
}
|
|
2728
|
+
function promptTimeoutsI18nOutput() {
|
|
2729
|
+
return inquirer.prompt([
|
|
2730
|
+
{
|
|
2731
|
+
default: String(CONFIG_DEFAULTS.PHASE1_TIMEOUT),
|
|
2732
|
+
message: "Phase 1 timeout (ms)",
|
|
2733
|
+
name: "phase1TimeoutRaw",
|
|
2734
|
+
type: "input",
|
|
2735
|
+
validate: (input) => validateTimeoutInput(input, "Phase 1 timeout")
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
default: String(CONFIG_DEFAULTS.PHASE2_TIMEOUT),
|
|
2739
|
+
message: "Phase 2 timeout (ms)",
|
|
2740
|
+
name: "phase2TimeoutRaw",
|
|
2741
|
+
type: "input",
|
|
2742
|
+
validate: (input) => validateTimeoutInput(input, "Phase 2 timeout")
|
|
2743
|
+
},
|
|
2744
|
+
{
|
|
2745
|
+
default: false,
|
|
2746
|
+
message: "Enable i18n ESLint rules (no-literal-string in JSX text)?",
|
|
2747
|
+
name: "enableI18nRules",
|
|
2748
|
+
type: "confirm"
|
|
2749
|
+
},
|
|
2750
|
+
{
|
|
2751
|
+
default: path13.join(process.cwd(), CONFIG_FILE_NAMES.YAML),
|
|
2752
|
+
message: "Path for the new config file",
|
|
2753
|
+
name: "outputPath",
|
|
2754
|
+
type: "input",
|
|
2755
|
+
validate: (input) => validateRequiredField(input, "Path is required")
|
|
2756
|
+
}
|
|
2757
|
+
]);
|
|
2758
|
+
}
|
|
2759
|
+
async function promptFixersTimeoutsOutput() {
|
|
2760
|
+
const chk = await promptFixerCheckbox();
|
|
2761
|
+
const rest = await promptTimeoutsI18nOutput();
|
|
2762
|
+
return { ...chk, ...rest };
|
|
2763
|
+
}
|
|
2764
|
+
async function confirmOverwrite(outputPath) {
|
|
2765
|
+
const { overwrite } = await inquirer.prompt([
|
|
2766
|
+
{
|
|
2767
|
+
default: false,
|
|
2768
|
+
message: `File exists: ${outputPath}. Overwrite?`,
|
|
2769
|
+
name: "overwrite",
|
|
2770
|
+
type: "confirm"
|
|
2771
|
+
}
|
|
2772
|
+
]);
|
|
2773
|
+
return overwrite;
|
|
2774
|
+
}
|
|
2775
|
+
function promptStep1() {
|
|
2776
|
+
return inquirer.prompt([
|
|
2777
|
+
{
|
|
2778
|
+
default: "./",
|
|
2779
|
+
message: "Project root (relative to the config file directory, or absolute)",
|
|
2780
|
+
name: "projectRoot",
|
|
2781
|
+
type: "input",
|
|
2782
|
+
validate: (input) => validateRequiredField(input, "Project root is required")
|
|
2783
|
+
},
|
|
2784
|
+
{
|
|
2785
|
+
default: false,
|
|
2786
|
+
message: "Enable SonarQube (Phase 2)?",
|
|
2787
|
+
name: "useSonar",
|
|
2788
|
+
type: "confirm"
|
|
2789
|
+
}
|
|
2790
|
+
]);
|
|
2791
|
+
}
|
|
2792
|
+
async function promptSetupWizardModel() {
|
|
2793
|
+
const step1 = await promptStep1();
|
|
2794
|
+
let sonar;
|
|
2795
|
+
if (step1.useSonar) sonar = await promptSonarBlock();
|
|
2796
|
+
const step2 = await promptFixersTimeoutsOutput();
|
|
2797
|
+
const outputPath = path13.resolve(step2.outputPath.trim());
|
|
2798
|
+
if (fs8__default.existsSync(outputPath)) {
|
|
2799
|
+
const ok = await confirmOverwrite(outputPath);
|
|
2800
|
+
if (!ok) return "aborted";
|
|
2801
|
+
}
|
|
2802
|
+
const fixers = fixersFromSelection(step2.fixerKeys);
|
|
2803
|
+
const phase1Timeout = parsePositiveInt(step2.phase1TimeoutRaw, "Phase 1 timeout");
|
|
2804
|
+
const phase2Timeout = parsePositiveInt(step2.phase2TimeoutRaw, "Phase 2 timeout");
|
|
2805
|
+
const model = {
|
|
2806
|
+
enableI18nRules: step2.enableI18nRules,
|
|
2807
|
+
fixers,
|
|
2808
|
+
outputPath,
|
|
2809
|
+
phase1Timeout,
|
|
2810
|
+
phase2Timeout,
|
|
2811
|
+
projectRoot: step1.projectRoot.trim()
|
|
2812
|
+
};
|
|
2813
|
+
if (sonar !== void 0) model.sonar = sonar;
|
|
2814
|
+
return model;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// src/cli/setup.ts
|
|
2818
|
+
async function runSetup() {
|
|
2819
|
+
try {
|
|
2820
|
+
const result = await promptSetupWizardModel();
|
|
2821
|
+
if (result === "aborted") {
|
|
2822
|
+
console.log("Aborted.");
|
|
2823
|
+
return EXIT_CODE.ERROR;
|
|
2824
|
+
}
|
|
2825
|
+
const yaml = buildQualityGateYamlFromModel(result);
|
|
2826
|
+
fs8__default.writeFileSync(result.outputPath, yaml, "utf8");
|
|
2827
|
+
console.log(`
|
|
2828
|
+
Wrote ${result.outputPath}
|
|
2829
|
+
`);
|
|
2830
|
+
return EXIT_CODE.SUCCESS;
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2833
|
+
console.error(`Setup failed: ${message}`);
|
|
2834
|
+
return EXIT_CODE.ERROR;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// src/cli/run.ts
|
|
2839
|
+
function printHelp() {
|
|
2840
|
+
const lines = [
|
|
2841
|
+
"Usage: ai-quality-gate [options] <file ...>",
|
|
2842
|
+
"",
|
|
2843
|
+
"Options:",
|
|
2844
|
+
` --${CLI_OPTION.CHECK} Read-only: typecheck + lint (no auto-fix)`,
|
|
2845
|
+
` --${CLI_OPTION.FIX} Apply fixes (ESLint --fix, Prettier, AST)`,
|
|
2846
|
+
` --${CLI_OPTION.PHASE1_ONLY} Run Phase 1 only`,
|
|
2847
|
+
` --${CLI_OPTION.PHASE2_ONLY} Run Phase 2 (SonarQube) only`,
|
|
2848
|
+
` --${CLI_OPTION.CONFIG} <path> Path to .quality-gate.yaml / .quality-gate.json`,
|
|
2849
|
+
` --${CLI_OPTION.SETUP} Interactive wizard to create .quality-gate.yaml`,
|
|
2850
|
+
` -h, --${CLI_OPTION.HELP} Show help`,
|
|
2851
|
+
` --${CLI_OPTION.VERSION} Print version`,
|
|
2852
|
+
"",
|
|
2853
|
+
"Exit codes:",
|
|
2854
|
+
` ${String(EXIT_CODE.SUCCESS)} Success`,
|
|
2855
|
+
` ${String(EXIT_CODE.QUALITY_FAILED)} Quality checks failed`,
|
|
2856
|
+
` ${String(EXIT_CODE.ERROR)} Configuration or runtime error`,
|
|
2857
|
+
""
|
|
2858
|
+
];
|
|
2859
|
+
console.log(lines.join("\n"));
|
|
2860
|
+
}
|
|
2861
|
+
function printVersion() {
|
|
2862
|
+
console.log(getPackageVersion());
|
|
2863
|
+
}
|
|
2864
|
+
function mapExitCode(result) {
|
|
2865
|
+
if (result.error !== void 0) return EXIT_CODE.ERROR;
|
|
2866
|
+
if (!result.success) return EXIT_CODE.QUALITY_FAILED;
|
|
2867
|
+
return EXIT_CODE.SUCCESS;
|
|
2868
|
+
}
|
|
2869
|
+
function buildEmptyCliResponse(skippedCount, skippedList) {
|
|
2870
|
+
return {
|
|
2871
|
+
phase: PHASE.COMPLETE,
|
|
2872
|
+
success: true,
|
|
2873
|
+
message: `No code files to check. Skipped ${String(skippedCount)} non-code file(s): ${skippedList}`,
|
|
2874
|
+
fixed: { eslint: 0, curlyBraces: 0, singleLineArrow: 0, prettier: 0, json: 0 },
|
|
2875
|
+
remaining: [],
|
|
2876
|
+
timing: { phase1: "0ms", total: "0ms" }
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
function buildExecutionErrorResponse(message) {
|
|
2880
|
+
return {
|
|
2881
|
+
phase: PHASE.LOCAL,
|
|
2882
|
+
success: false,
|
|
2883
|
+
message: "Execution error",
|
|
2884
|
+
fixed: { eslint: 0, curlyBraces: 0, singleLineArrow: 0, prettier: 0, json: 0 },
|
|
2885
|
+
remaining: [],
|
|
2886
|
+
timing: { phase1: "0ms", total: "0ms" },
|
|
2887
|
+
error: { code: "UNEXPECTED_ERROR", message }
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
async function runQualityPayload(payload) {
|
|
2891
|
+
if (payload.configPath !== void 0) process.env[ENV_KEYS.QUALITY_GATE_CONFIG] = path13.resolve(payload.configPath);
|
|
2892
|
+
const config = configManager.load();
|
|
2893
|
+
const { codeFiles, skippedFiles } = filterCodeFiles(payload.files);
|
|
2894
|
+
if (codeFiles.length === 0) {
|
|
2895
|
+
const empty = buildEmptyCliResponse(skippedFiles.length, skippedFiles.join(", "));
|
|
2896
|
+
console.log(JSON.stringify(empty, null, 2));
|
|
2897
|
+
return mapExitCode(empty);
|
|
2898
|
+
}
|
|
2899
|
+
const qualityGate = new QualityGate(config);
|
|
2900
|
+
const result = await qualityGate.run(codeFiles, payload.qualityGateOptions);
|
|
2901
|
+
if (skippedFiles.length > 0) result.message += ` (Skipped ${String(skippedFiles.length)} non-code file(s))`;
|
|
2902
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2903
|
+
return mapExitCode(result);
|
|
2904
|
+
}
|
|
2905
|
+
async function runCli(argv) {
|
|
2906
|
+
const parsed = parseCliArgs(argv);
|
|
2907
|
+
if (!parsed.ok) {
|
|
2908
|
+
console.error(parsed.error);
|
|
2909
|
+
return EXIT_CODE.ERROR;
|
|
2910
|
+
}
|
|
2911
|
+
if (parsed.kind === "help") {
|
|
2912
|
+
printHelp();
|
|
2913
|
+
return EXIT_CODE.SUCCESS;
|
|
2914
|
+
}
|
|
2915
|
+
if (parsed.kind === "version") {
|
|
2916
|
+
printVersion();
|
|
2917
|
+
return EXIT_CODE.SUCCESS;
|
|
2918
|
+
}
|
|
2919
|
+
if (parsed.kind === "setup") return runSetup();
|
|
2920
|
+
try {
|
|
2921
|
+
return await runQualityPayload(parsed.payload);
|
|
2922
|
+
} catch (error) {
|
|
2923
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2924
|
+
const response = buildExecutionErrorResponse(message);
|
|
2925
|
+
console.log(JSON.stringify(response, null, 2));
|
|
2926
|
+
return EXIT_CODE.ERROR;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// src/server/qualityFixHandlers.ts
|
|
2931
|
+
var toToolResponse = (response) => ({
|
|
2932
|
+
content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
|
|
2933
|
+
});
|
|
2934
|
+
var buildEmptyResponse = (skippedCount, skippedList) => ({
|
|
2935
|
+
phase: PHASE.COMPLETE,
|
|
2936
|
+
success: true,
|
|
2937
|
+
message: `\u2705 No code files to check. Skipped ${skippedCount} non-code file(s): ${skippedList}`,
|
|
2938
|
+
fixed: { eslint: 0, curlyBraces: 0, singleLineArrow: 0, prettier: 0, json: 0 },
|
|
2939
|
+
remaining: [],
|
|
2940
|
+
timing: { phase1: "0ms", total: "0ms" }
|
|
2941
|
+
});
|
|
2942
|
+
var buildErrorResponse = (errorMessage) => ({
|
|
2943
|
+
phase: PHASE.LOCAL,
|
|
2944
|
+
success: false,
|
|
2945
|
+
message: "Configuration or execution error",
|
|
2946
|
+
fixed: { eslint: 0, curlyBraces: 0, singleLineArrow: 0, prettier: 0, json: 0 },
|
|
2947
|
+
remaining: [],
|
|
2948
|
+
timing: { phase1: "0ms", total: "0ms" },
|
|
2949
|
+
error: { code: "CONFIG_INVALID", message: errorMessage }
|
|
2950
|
+
});
|
|
2951
|
+
async function runQualityFixForFiles(files) {
|
|
2952
|
+
try {
|
|
2953
|
+
const { codeFiles, skippedFiles } = filterCodeFiles(files);
|
|
2954
|
+
if (codeFiles.length === 0) return buildEmptyResponse(skippedFiles.length, skippedFiles.join(", "));
|
|
2955
|
+
const config = configManager.load();
|
|
2956
|
+
const qualityGate = new QualityGate(config);
|
|
2957
|
+
const result = await qualityGate.run(codeFiles);
|
|
2958
|
+
if (skippedFiles.length > 0) result.message += ` (Skipped ${skippedFiles.length} non-code file(s))`;
|
|
2959
|
+
return result;
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2962
|
+
return buildErrorResponse(errorMessage);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// src/server.ts
|
|
2967
|
+
var PACKAGE_VERSION = getPackageVersion();
|
|
2968
|
+
var TOOL_DESCRIPTION = `Run quality checks on code files and auto-fix deterministic issues.
|
|
2969
|
+
|
|
2970
|
+
Phase 1 (Local, ~2-3s): TypeScript + ESLint + SonarJS (~60 rules) + AST fixers
|
|
2971
|
+
Phase 2 (Server, optional): Deep SonarQube analysis (only if configured)
|
|
2972
|
+
|
|
2973
|
+
\u{1F3AF} Phase 1 is comprehensive - most users won't need Phase 2!
|
|
2974
|
+
|
|
2975
|
+
Auto-fixes (ESLint + AST):
|
|
2976
|
+
- Curly braces on single-statement if (AST)
|
|
2977
|
+
- Single-expression arrow bodies where safe (AST)
|
|
2978
|
+
|
|
2979
|
+
Code Quality Limits:
|
|
2980
|
+
- File: max 400 lines
|
|
2981
|
+
- Function: max 50 lines, max 5 params, max 4 depth, max 20 statements
|
|
2982
|
+
- Complexity: Cognitive max 15, Cyclomatic max 10
|
|
2983
|
+
- Security: no-eval, no-new-func, no-implied-eval
|
|
2984
|
+
- Best Practices: eqeqeq, no-throw-literal, prefer-promise-reject-errors`;
|
|
2985
|
+
var server = new McpServer({ name: "ai-quality-gate", version: PACKAGE_VERSION });
|
|
2986
|
+
server.registerTool(
|
|
2987
|
+
"quality_fix",
|
|
2988
|
+
{
|
|
2989
|
+
description: TOOL_DESCRIPTION,
|
|
2990
|
+
inputSchema: { files: z2.array(z2.string()).min(1).describe("Array of file paths to check") }
|
|
2991
|
+
},
|
|
2992
|
+
async ({ files }) => toToolResponse(await runQualityFixForFiles(files))
|
|
2993
|
+
);
|
|
2994
|
+
if (shouldUseCliMode(process.argv)) {
|
|
2995
|
+
const code = await runCli(process.argv);
|
|
2996
|
+
process.exit(code);
|
|
2997
|
+
}
|
|
2998
|
+
try {
|
|
2999
|
+
const transport = new StdioServerTransport();
|
|
3000
|
+
await server.connect(transport);
|
|
3001
|
+
console.error("[ai-quality-gate] MCP Server started");
|
|
3002
|
+
} catch (error) {
|
|
3003
|
+
console.error("[ai-quality-gate] Fatal error:", error);
|
|
3004
|
+
process.exit(1);
|
|
3005
|
+
}
|
|
3006
|
+
//# sourceMappingURL=server.js.map
|
|
3007
|
+
//# sourceMappingURL=server.js.map
|