@kb-labs/qa-core 2.93.0 → 2.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +72 -6
- package/dist/index.js +562 -1553
- package/dist/index.js.map +1 -1
- package/dist/report/index.d.ts +14 -45
- package/dist/report/index.js +180 -410
- package/dist/report/index.js.map +1 -1
- package/package.json +5 -16
- package/dist/baseline/index.d.ts +0 -27
- package/dist/baseline/index.js +0 -838
- package/dist/baseline/index.js.map +0 -1
- package/dist/categories/index.d.ts +0 -13
- package/dist/categories/index.js +0 -39
- package/dist/categories/index.js.map +0 -1
- package/dist/history/index.d.ts +0 -58
- package/dist/history/index.js +0 -361
- package/dist/history/index.js.map +0 -1
- package/dist/last-run-store-C1AkskWI.d.ts +0 -81
- package/dist/runner/index.d.ts +0 -43
- package/dist/runner/index.js +0 -789
- package/dist/runner/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1692 +1,701 @@
|
|
|
1
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync
|
|
2
|
-
import { join,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { TRENDS_WINDOW, PATHS, HISTORY_MAX_ENTRIES, getCheckIcon, getCheckLabel, QA_DATA_DIR } from '@kb-labs/qa-contracts';
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { TRENDS_WINDOW, HISTORY_MAX_ENTRIES, PATHS } from '@kb-labs/qa-contracts';
|
|
6
5
|
|
|
7
|
-
// src/
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
try {
|
|
14
|
-
const commit = execSync("git rev-parse --short HEAD", {
|
|
15
|
-
cwd: repoDir,
|
|
16
|
-
encoding: "utf-8",
|
|
17
|
-
timeout: 5e3,
|
|
18
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
19
|
-
}).trim();
|
|
20
|
-
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
21
|
-
cwd: repoDir,
|
|
22
|
-
encoding: "utf-8",
|
|
23
|
-
timeout: 5e3,
|
|
24
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
25
|
-
}).trim();
|
|
26
|
-
const message = execSync("git log -1 --format=%s", {
|
|
27
|
-
cwd: repoDir,
|
|
28
|
-
encoding: "utf-8",
|
|
29
|
-
timeout: 5e3,
|
|
30
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
31
|
-
}).trim();
|
|
32
|
-
const statusOutput = execSync("git status --porcelain", {
|
|
33
|
-
cwd: repoDir,
|
|
34
|
-
encoding: "utf-8",
|
|
35
|
-
timeout: 5e3,
|
|
36
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
37
|
-
}).trim();
|
|
38
|
-
return {
|
|
39
|
-
name: repoName,
|
|
40
|
-
commit,
|
|
41
|
-
branch,
|
|
42
|
-
dirty: statusOutput.length > 0,
|
|
43
|
-
message
|
|
44
|
-
};
|
|
45
|
-
} catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function collectSubmoduleInfo(rootDir, repos) {
|
|
50
|
-
const result = {};
|
|
51
|
-
for (const repo of repos) {
|
|
52
|
-
const repoDir = join(rootDir, repo);
|
|
53
|
-
const info = getSubmoduleInfo(repoDir, repo);
|
|
54
|
-
if (info) {
|
|
55
|
-
result[repo] = info;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return result;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// src/runner/workspace.ts
|
|
62
|
-
function hasWorkspace(dir) {
|
|
63
|
-
return existsSync(join(dir, "pnpm-workspace.yaml"));
|
|
64
|
-
}
|
|
65
|
-
function isDir(p) {
|
|
66
|
-
return existsSync(p) && statSync(p).isDirectory();
|
|
67
|
-
}
|
|
68
|
-
function buildCandidatesFromConfig(rootDir, paths) {
|
|
69
|
-
const candidates = [];
|
|
70
|
-
for (const pattern of paths) {
|
|
71
|
-
const parts = pattern.split("/");
|
|
72
|
-
if (parts.length === 2 && parts[1] === "*" && parts[0]) {
|
|
73
|
-
const categoryDir = join(rootDir, parts[0]);
|
|
74
|
-
if (!isDir(categoryDir)) {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
try {
|
|
78
|
-
for (const sub of readdirSync(categoryDir)) {
|
|
79
|
-
if (sub.startsWith(".") || sub === "node_modules") {
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const subPath = join(categoryDir, sub);
|
|
83
|
-
if (isDir(subPath) && hasWorkspace(subPath)) {
|
|
84
|
-
candidates.push(subPath);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
}
|
|
89
|
-
} else {
|
|
90
|
-
const exactPath = join(rootDir, pattern);
|
|
91
|
-
if (isDir(exactPath) && hasWorkspace(exactPath)) {
|
|
92
|
-
candidates.push(exactPath);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return candidates;
|
|
97
|
-
}
|
|
98
|
-
function buildCandidatesAutoScan(rootDir) {
|
|
99
|
-
const candidates = [];
|
|
100
|
-
for (const entry of readdirSync(rootDir)) {
|
|
101
|
-
if (entry.startsWith(".") || entry === "node_modules" || entry === "dist") {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const entryPath = join(rootDir, entry);
|
|
105
|
-
if (!isDir(entryPath)) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (hasWorkspace(entryPath)) {
|
|
109
|
-
candidates.push(entryPath);
|
|
110
|
-
} else {
|
|
111
|
-
try {
|
|
112
|
-
for (const sub of readdirSync(entryPath)) {
|
|
113
|
-
if (sub.startsWith(".") || sub === "node_modules") {
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
const subPath = join(entryPath, sub);
|
|
117
|
-
if (isDir(subPath) && hasWorkspace(subPath)) {
|
|
118
|
-
candidates.push(subPath);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
} catch {
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return candidates;
|
|
126
|
-
}
|
|
127
|
-
function scanSubDir(parentDir, entryPath, repoName, rootDir, submodule, packages) {
|
|
128
|
-
if (!isDir(parentDir)) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
for (const pkgDir of readdirSync(parentDir)) {
|
|
132
|
-
const pkgPath = join(parentDir, pkgDir);
|
|
133
|
-
const pkgJsonPath = join(pkgPath, "package.json");
|
|
134
|
-
if (!existsSync(pkgJsonPath)) {
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
139
|
-
packages.push({
|
|
140
|
-
name: pkgJson.name || pkgDir,
|
|
141
|
-
dir: pkgPath,
|
|
142
|
-
relativePath: relative(rootDir, pkgPath),
|
|
143
|
-
repo: repoName,
|
|
144
|
-
submodule
|
|
145
|
-
});
|
|
146
|
-
} catch {
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
function getWorkspacePackages(rootDir, filter, packagesConfig) {
|
|
151
|
-
const packages = [];
|
|
152
|
-
const submoduleCache = /* @__PURE__ */ new Map();
|
|
153
|
-
function getSubmoduleCached(entryPath, repoName) {
|
|
154
|
-
if (!submoduleCache.has(repoName)) {
|
|
155
|
-
submoduleCache.set(repoName, getSubmoduleInfo(entryPath, repoName));
|
|
156
|
-
}
|
|
157
|
-
return submoduleCache.get(repoName) ?? void 0;
|
|
158
|
-
}
|
|
159
|
-
const candidates = packagesConfig?.paths && packagesConfig.paths.length > 0 ? buildCandidatesFromConfig(rootDir, packagesConfig.paths) : buildCandidatesAutoScan(rootDir);
|
|
160
|
-
for (const entryPath of candidates) {
|
|
161
|
-
const repoName = relative(rootDir, entryPath);
|
|
162
|
-
const submodule = getSubmoduleCached(entryPath, repoName);
|
|
163
|
-
scanSubDir(join(entryPath, "packages"), entryPath, repoName, rootDir, submodule, packages);
|
|
164
|
-
scanSubDir(join(entryPath, "apps"), entryPath, repoName, rootDir, submodule, packages);
|
|
165
|
-
}
|
|
166
|
-
let filtered = packages;
|
|
167
|
-
if (packagesConfig?.include && packagesConfig.include.length > 0) {
|
|
168
|
-
filtered = filtered.filter(
|
|
169
|
-
(pkg) => packagesConfig.include.some((pattern) => matchesPattern(pkg.name, pkg.repo, pattern))
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
if (packagesConfig?.exclude && packagesConfig.exclude.length > 0) {
|
|
173
|
-
filtered = filtered.filter(
|
|
174
|
-
(pkg) => !packagesConfig.exclude.some((pattern) => matchesPattern(pkg.name, pkg.repo, pattern))
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
if (!filter) {
|
|
178
|
-
return filtered;
|
|
179
|
-
}
|
|
180
|
-
return filtered.filter((pkg) => {
|
|
181
|
-
if (filter.package && !pkg.name.includes(filter.package)) {
|
|
182
|
-
return false;
|
|
183
|
-
}
|
|
184
|
-
if (filter.repo && pkg.repo !== filter.repo) {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
if (filter.scope) {
|
|
188
|
-
const scope = filter.scope.startsWith("@") ? filter.scope : `@${filter.scope}`;
|
|
189
|
-
if (!pkg.name.startsWith(scope)) {
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return true;
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
function matchesPattern(name, repo, pattern) {
|
|
197
|
-
if (pattern.endsWith("/*")) {
|
|
198
|
-
const prefix = pattern.slice(0, -2);
|
|
199
|
-
return repo === prefix || repo.startsWith(prefix + "/");
|
|
200
|
-
}
|
|
201
|
-
if (pattern.endsWith("*")) {
|
|
202
|
-
return name.startsWith(pattern.slice(0, -1));
|
|
203
|
-
}
|
|
204
|
-
return name === pattern || repo === pattern;
|
|
205
|
-
}
|
|
206
|
-
function readWorkspaceDeps(pkgDir, workspaceNames) {
|
|
207
|
-
try {
|
|
208
|
-
const raw = readFileSync(join(pkgDir, "package.json"), "utf-8");
|
|
209
|
-
const pkgJson = JSON.parse(raw);
|
|
210
|
-
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
|
|
211
|
-
return Object.keys(allDeps).filter((dep) => workspaceNames.has(dep));
|
|
212
|
-
} catch {
|
|
213
|
-
return [];
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
function buildDepGraph(packages) {
|
|
217
|
-
const nameMap = /* @__PURE__ */ new Map();
|
|
218
|
-
const workspaceNames = /* @__PURE__ */ new Set();
|
|
219
|
-
for (const pkg of packages) {
|
|
220
|
-
nameMap.set(pkg.name, pkg);
|
|
221
|
-
workspaceNames.add(pkg.name);
|
|
222
|
-
}
|
|
223
|
-
const inDegree = /* @__PURE__ */ new Map();
|
|
224
|
-
const dependents = /* @__PURE__ */ new Map();
|
|
225
|
-
for (const pkg of packages) {
|
|
226
|
-
inDegree.set(pkg.name, 0);
|
|
227
|
-
dependents.set(pkg.name, []);
|
|
228
|
-
}
|
|
229
|
-
for (const pkg of packages) {
|
|
230
|
-
const deps = readWorkspaceDeps(pkg.dir, workspaceNames);
|
|
231
|
-
inDegree.set(pkg.name, deps.length);
|
|
232
|
-
for (const dep of deps) {
|
|
233
|
-
dependents.get(dep).push(pkg.name);
|
|
6
|
+
// src/devkit/devkit-discovery.ts
|
|
7
|
+
function resolveDevkitBin(rootDir, configPath) {
|
|
8
|
+
if (configPath) {
|
|
9
|
+
const full = configPath.startsWith("/") ? configPath : join(rootDir, configPath);
|
|
10
|
+
if (!existsSync(full)) {
|
|
11
|
+
throw new Error(`devkitPath "${configPath}" not found at ${full}`);
|
|
234
12
|
}
|
|
13
|
+
return full;
|
|
235
14
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (packages.length === 0) {
|
|
240
|
-
return [];
|
|
15
|
+
const local = join(rootDir, "tools", "kb-devkit", "kb-devkit");
|
|
16
|
+
if (existsSync(local)) {
|
|
17
|
+
return local;
|
|
241
18
|
}
|
|
242
|
-
|
|
243
|
-
const layers = [];
|
|
244
|
-
const remaining = new Set(packages.map((p) => p.name));
|
|
245
|
-
while (remaining.size > 0) {
|
|
246
|
-
const layerNames = [];
|
|
247
|
-
for (const name of remaining) {
|
|
248
|
-
if ((inDegree.get(name) ?? 0) === 0) {
|
|
249
|
-
layerNames.push(name);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (layerNames.length === 0) {
|
|
253
|
-
const circular = [...remaining].map((n) => nameMap.get(n)).filter(Boolean);
|
|
254
|
-
layers.push({ index: layers.length, packages: circular });
|
|
255
|
-
break;
|
|
256
|
-
}
|
|
257
|
-
layerNames.sort();
|
|
258
|
-
layers.push({ index: layers.length, packages: layerNames.map((n) => nameMap.get(n)) });
|
|
259
|
-
for (const name of layerNames) {
|
|
260
|
-
remaining.delete(name);
|
|
261
|
-
for (const dependent of dependents.get(name) ?? []) {
|
|
262
|
-
inDegree.set(dependent, (inDegree.get(dependent) ?? 0) - 1);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return layers;
|
|
267
|
-
}
|
|
268
|
-
function sortByBuildLayers(packages) {
|
|
269
|
-
return computeBuildLayers(packages).flatMap((l) => l.packages);
|
|
19
|
+
return "kb-devkit";
|
|
270
20
|
}
|
|
271
21
|
|
|
272
|
-
// src/
|
|
273
|
-
var
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
return
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
22
|
+
// src/devkit/devkit-adapter.ts
|
|
23
|
+
var DevkitAdapter = class {
|
|
24
|
+
bin;
|
|
25
|
+
cwd;
|
|
26
|
+
shell;
|
|
27
|
+
timeout;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.bin = opts.binaryPath;
|
|
30
|
+
this.cwd = opts.cwd;
|
|
31
|
+
this.shell = opts.shell;
|
|
32
|
+
this.timeout = opts.timeoutMs ?? 6e5;
|
|
33
|
+
}
|
|
34
|
+
async run(tasks) {
|
|
35
|
+
if (tasks.length === 0) {
|
|
36
|
+
throw new Error("DevkitAdapter.run: tasks must not be empty");
|
|
37
|
+
}
|
|
38
|
+
return this.invoke(["run", ...tasks]);
|
|
39
|
+
}
|
|
40
|
+
async check() {
|
|
41
|
+
return this.invoke(["check"]);
|
|
42
|
+
}
|
|
43
|
+
async stats() {
|
|
44
|
+
return this.invoke(["stats"]);
|
|
45
|
+
}
|
|
46
|
+
async status() {
|
|
47
|
+
return this.invoke(["status"]);
|
|
48
|
+
}
|
|
49
|
+
async gate() {
|
|
50
|
+
return this.invoke(["gate"]);
|
|
51
|
+
}
|
|
52
|
+
async version() {
|
|
53
|
+
const result = await this.shell.exec(this.bin, ["--version"], {
|
|
54
|
+
cwd: this.cwd,
|
|
55
|
+
timeout: 5e3,
|
|
56
|
+
throwOnError: false
|
|
57
|
+
});
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`kb-devkit not found or not executable at "${this.bin}". Set QAPluginConfig.devkitPath or ensure kb-devkit is on PATH.`
|
|
61
|
+
);
|
|
304
62
|
}
|
|
305
|
-
|
|
63
|
+
return result.stdout.trim();
|
|
306
64
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
65
|
+
async invoke(args) {
|
|
66
|
+
const result = await this.shell.exec(this.bin, [...args, "--json"], {
|
|
67
|
+
cwd: this.cwd,
|
|
68
|
+
timeout: this.timeout,
|
|
69
|
+
throwOnError: false
|
|
70
|
+
});
|
|
71
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`kb-devkit ${args[0]} failed (exit ${result.code}): ${result.stderr.slice(0, 500)}`
|
|
74
|
+
);
|
|
314
75
|
}
|
|
315
76
|
try {
|
|
316
|
-
|
|
317
|
-
return parsed.ok === true || parsed.success === true || parsed.status === "ok";
|
|
77
|
+
return JSON.parse(result.stdout);
|
|
318
78
|
} catch {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return exitCode === 0;
|
|
323
|
-
}
|
|
324
|
-
function recordResult(bucket, key, passed, stdout, stderr, exitCode, check, canonicalId, onProgress, durationMs) {
|
|
325
|
-
if (passed) {
|
|
326
|
-
bucket.passed.push(key);
|
|
327
|
-
onProgress?.(canonicalId, key, "pass", durationMs);
|
|
328
|
-
} else {
|
|
329
|
-
bucket.failed.push(key);
|
|
330
|
-
bucket.errors[key] = stderr || stdout || `Exit code ${exitCode}`;
|
|
331
|
-
if ((check.parser ?? "exitcode") === "json") {
|
|
332
|
-
const typed = parseTypedOutput(stdout);
|
|
333
|
-
if (typed && typed.items.length > 0) {
|
|
334
|
-
bucket.details[key] = typed.items;
|
|
335
|
-
}
|
|
79
|
+
throw new Error(
|
|
80
|
+
`kb-devkit ${args[0]} produced non-JSON output (exit ${result.code}). stderr: ${result.stderr.slice(0, 300)}`
|
|
81
|
+
);
|
|
336
82
|
}
|
|
337
|
-
onProgress?.(canonicalId, key, "fail", durationMs);
|
|
338
83
|
}
|
|
339
|
-
}
|
|
340
|
-
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// src/devkit/git-capture.ts
|
|
87
|
+
async function captureGit(shell, cwd) {
|
|
341
88
|
try {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
89
|
+
const [commitRes, branchRes, messageRes] = await Promise.all([
|
|
90
|
+
shell.exec("git", ["rev-parse", "--short", "HEAD"], { cwd, throwOnError: false }),
|
|
91
|
+
shell.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, throwOnError: false }),
|
|
92
|
+
shell.exec("git", ["log", "-1", "--format=%s"], { cwd, throwOnError: false })
|
|
93
|
+
]);
|
|
94
|
+
if (!commitRes.ok) {
|
|
95
|
+
return void 0;
|
|
347
96
|
}
|
|
348
|
-
return
|
|
97
|
+
return {
|
|
98
|
+
commit: commitRes.stdout.trim(),
|
|
99
|
+
branch: branchRes.stdout.trim() || "HEAD",
|
|
100
|
+
message: messageRes.stdout.trim()
|
|
101
|
+
};
|
|
349
102
|
} catch {
|
|
350
|
-
return /* @__PURE__ */ new Set();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
function filterPackagesByDiff(packages, rootDir, diffFilter, base) {
|
|
354
|
-
const touchedDirs = getDiffPackageDirs(rootDir, base, diffFilter);
|
|
355
|
-
if (touchedDirs.size === 0) {
|
|
356
|
-
return [];
|
|
357
|
-
}
|
|
358
|
-
return packages.filter((pkg) => {
|
|
359
|
-
const relParts = pkg.relativePath.split("/");
|
|
360
|
-
return relParts.some((_, i) => touchedDirs.has(relParts.slice(0, i + 1).join("/")));
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
function runInRepoRoot(check, canonicalId, resolvedArgs, rootDir, bucket, onProgress, diffBase) {
|
|
364
|
-
const startMs = Date.now();
|
|
365
|
-
const env = diffBase ? { KB_QA_BASE: diffBase } : void 0;
|
|
366
|
-
const { stderr, exitCode, stdout } = runCommand(check.command, resolvedArgs, rootDir, check.timeoutMs ?? 12e4, env);
|
|
367
|
-
recordResult(bucket, rootDir, evaluate(check, stdout, stderr, exitCode), stdout, stderr, exitCode, check, canonicalId, onProgress, Date.now() - startMs);
|
|
368
|
-
}
|
|
369
|
-
function runInScopePath(check, canonicalId, resolvedArgs, packages, rootDir, bucket, onProgress, diffBase) {
|
|
370
|
-
const seen = /* @__PURE__ */ new Set();
|
|
371
|
-
const env = diffBase ? { KB_QA_BASE: diffBase } : void 0;
|
|
372
|
-
for (const pkg of packages) {
|
|
373
|
-
if (seen.has(pkg.repo)) {
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
seen.add(pkg.repo);
|
|
377
|
-
const scopeDir = resolve(rootDir, pkg.repo);
|
|
378
|
-
if (!existsSync(scopeDir)) {
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
const startMs = Date.now();
|
|
382
|
-
const { stderr, exitCode, stdout } = runCommand(check.command, resolvedArgs, scopeDir, check.timeoutMs ?? 12e4, env);
|
|
383
|
-
recordResult(bucket, pkg.repo, evaluate(check, stdout, stderr, exitCode), stdout, stderr, exitCode, check, canonicalId, onProgress, Date.now() - startMs);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
function getPnpmScriptName(check) {
|
|
387
|
-
if (check.command !== "pnpm") {
|
|
388
103
|
return void 0;
|
|
389
104
|
}
|
|
390
|
-
const args = check.args ?? [];
|
|
391
|
-
if (args[0] === "run" && args[1]) {
|
|
392
|
-
return args[1];
|
|
393
|
-
}
|
|
394
|
-
return void 0;
|
|
395
|
-
}
|
|
396
|
-
function hasNpmScript(pkgDir, scriptName) {
|
|
397
|
-
try {
|
|
398
|
-
const pkgJson = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8"));
|
|
399
|
-
return typeof pkgJson?.scripts?.[scriptName] === "string";
|
|
400
|
-
} catch {
|
|
401
|
-
return false;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
function runPerPackage(check, canonicalId, resolvedArgs, packages, bucket, onProgress, diffBase) {
|
|
405
|
-
const sortedPackages = check.ordered ? sortByBuildLayers(packages) : packages;
|
|
406
|
-
const scriptName = getPnpmScriptName(check);
|
|
407
|
-
const env = diffBase ? { KB_QA_BASE: diffBase } : void 0;
|
|
408
|
-
for (const pkg of sortedPackages) {
|
|
409
|
-
if (scriptName && !hasNpmScript(pkg.dir, scriptName)) {
|
|
410
|
-
bucket.skipped.push(pkg.name);
|
|
411
|
-
onProgress?.(canonicalId, pkg.name, "skip");
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
const startMs = Date.now();
|
|
415
|
-
const { stderr, exitCode, stdout } = runCommand(check.command, resolvedArgs, pkg.dir, check.timeoutMs ?? 12e4, env);
|
|
416
|
-
recordResult(bucket, pkg.name, evaluate(check, stdout, stderr, exitCode), stdout, stderr, exitCode, check, canonicalId, onProgress, Date.now() - startMs);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
function runCustomChecks(checks, packages, rootDir, onProgress, diffBase) {
|
|
420
|
-
const results = {};
|
|
421
|
-
for (const check of checks) {
|
|
422
|
-
const canonicalId = ID_MAP[check.id.toLowerCase()] ?? check.id;
|
|
423
|
-
if (!results[canonicalId]) {
|
|
424
|
-
results[canonicalId] = emptyResult();
|
|
425
|
-
}
|
|
426
|
-
const bucket = results[canonicalId];
|
|
427
|
-
const args = check.args ?? [];
|
|
428
|
-
const resolvedArgs = args.map(
|
|
429
|
-
(arg) => arg.match(/\.(sh|js|ts|mjs|cjs)$/) && !arg.startsWith("/") ? join(rootDir, arg) : arg
|
|
430
|
-
);
|
|
431
|
-
const runIn = check.runIn ?? "perPackage";
|
|
432
|
-
if (runIn === "repoRoot") {
|
|
433
|
-
runInRepoRoot(check, canonicalId, resolvedArgs, rootDir, bucket, onProgress, diffBase);
|
|
434
|
-
} else if (runIn === "scopePath") {
|
|
435
|
-
runInScopePath(check, canonicalId, resolvedArgs, packages, rootDir, bucket, onProgress, diffBase);
|
|
436
|
-
} else if (runIn === "diffOnly" || runIn === "newFiles") {
|
|
437
|
-
const base = diffBase ?? "main";
|
|
438
|
-
const diffFilter = runIn === "newFiles" ? "A" : "";
|
|
439
|
-
const filteredPackages = filterPackagesByDiff(packages, rootDir, diffFilter, base);
|
|
440
|
-
if (filteredPackages.length === 0) {
|
|
441
|
-
bucket.skipped.push(...packages.map((p) => p.name));
|
|
442
|
-
for (const pkg of packages) {
|
|
443
|
-
onProgress?.(canonicalId, pkg.name, "skip");
|
|
444
|
-
}
|
|
445
|
-
} else {
|
|
446
|
-
const filteredNames = new Set(filteredPackages.map((p) => p.name));
|
|
447
|
-
for (const pkg of packages) {
|
|
448
|
-
if (!filteredNames.has(pkg.name)) {
|
|
449
|
-
bucket.skipped.push(pkg.name);
|
|
450
|
-
onProgress?.(canonicalId, pkg.name, "skip");
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
runPerPackage(check, canonicalId, resolvedArgs, filteredPackages, bucket, onProgress, base);
|
|
454
|
-
}
|
|
455
|
-
} else {
|
|
456
|
-
runPerPackage(check, canonicalId, resolvedArgs, packages, bucket, onProgress);
|
|
457
|
-
}
|
|
458
|
-
if (Object.keys(bucket.details).length > 0) {
|
|
459
|
-
results[canonicalId].details = bucket.details;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return results;
|
|
463
105
|
}
|
|
464
|
-
function
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return {};
|
|
106
|
+
function readJson(filePath) {
|
|
107
|
+
if (!existsSync(filePath)) {
|
|
108
|
+
return [];
|
|
468
109
|
}
|
|
469
110
|
try {
|
|
470
|
-
return JSON.parse(readFileSync(
|
|
111
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
471
112
|
} catch {
|
|
472
|
-
return
|
|
113
|
+
return [];
|
|
473
114
|
}
|
|
474
115
|
}
|
|
475
|
-
function
|
|
476
|
-
const
|
|
477
|
-
const dir = dirname(cachePath);
|
|
116
|
+
function writeJson(filePath, data) {
|
|
117
|
+
const dir = dirname(filePath);
|
|
478
118
|
if (!existsSync(dir)) {
|
|
479
119
|
mkdirSync(dir, { recursive: true });
|
|
480
120
|
}
|
|
481
|
-
writeFileSync(
|
|
482
|
-
}
|
|
483
|
-
function computePackageHash(pkgDir) {
|
|
484
|
-
const hash = createHash("sha256");
|
|
485
|
-
const pkgJsonPath = join(pkgDir, "package.json");
|
|
486
|
-
if (existsSync(pkgJsonPath)) {
|
|
487
|
-
hash.update(readFileSync(pkgJsonPath));
|
|
488
|
-
}
|
|
489
|
-
const srcDir = join(pkgDir, "src");
|
|
490
|
-
if (existsSync(srcDir)) {
|
|
491
|
-
hashDirectory(srcDir, hash);
|
|
492
|
-
}
|
|
493
|
-
return hash.digest("hex");
|
|
494
|
-
}
|
|
495
|
-
function hashDirectory(dir, hash) {
|
|
496
|
-
const entries = readdirSync(dir).sort();
|
|
497
|
-
for (const entry of entries) {
|
|
498
|
-
const fullPath = join(dir, entry);
|
|
499
|
-
const stat = statSync(fullPath);
|
|
500
|
-
if (stat.isDirectory()) {
|
|
501
|
-
hashDirectory(fullPath, hash);
|
|
502
|
-
} else if (stat.isFile()) {
|
|
503
|
-
hash.update(fullPath);
|
|
504
|
-
hash.update(readFileSync(fullPath));
|
|
505
|
-
}
|
|
506
|
-
}
|
|
121
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
507
122
|
}
|
|
508
|
-
function
|
|
509
|
-
const hash = computePackageHash(pkgDir);
|
|
123
|
+
function buildMeta(durationMs, git, runContext) {
|
|
510
124
|
return {
|
|
511
|
-
|
|
512
|
-
|
|
125
|
+
id: randomUUID(),
|
|
126
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
127
|
+
durationMs,
|
|
128
|
+
...git ? { git } : {},
|
|
129
|
+
...runContext ? { runContext } : {}
|
|
513
130
|
};
|
|
514
131
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
132
|
+
var SnapshotStore = class {
|
|
133
|
+
rootDir;
|
|
134
|
+
maxEntries;
|
|
135
|
+
constructor(rootDir, maxEntries = HISTORY_MAX_ENTRIES) {
|
|
136
|
+
this.rootDir = rootDir;
|
|
137
|
+
this.maxEntries = maxEntries;
|
|
138
|
+
}
|
|
139
|
+
// ── Run snapshots ──────────────────────────────────────────────────────────
|
|
140
|
+
loadRunHistory() {
|
|
141
|
+
return readJson(join(this.rootDir, PATHS.SNAPSHOTS_RUN));
|
|
142
|
+
}
|
|
143
|
+
saveRun(raw, tasks, durationMs, git, runContext) {
|
|
144
|
+
const snap = {
|
|
145
|
+
...buildMeta(durationMs, git, runContext),
|
|
146
|
+
kind: "run",
|
|
147
|
+
tasks,
|
|
148
|
+
raw
|
|
149
|
+
};
|
|
150
|
+
const history = this.loadRunHistory();
|
|
151
|
+
history.push(snap);
|
|
152
|
+
while (history.length > this.maxEntries) {
|
|
153
|
+
history.shift();
|
|
154
|
+
}
|
|
155
|
+
writeJson(join(this.rootDir, PATHS.SNAPSHOTS_RUN), history);
|
|
156
|
+
return snap;
|
|
157
|
+
}
|
|
158
|
+
latestRun() {
|
|
159
|
+
const h = this.loadRunHistory();
|
|
160
|
+
return h.length > 0 ? h[h.length - 1] : null;
|
|
161
|
+
}
|
|
162
|
+
// ── Check snapshots ────────────────────────────────────────────────────────
|
|
163
|
+
loadCheckHistory() {
|
|
164
|
+
return readJson(join(this.rootDir, PATHS.SNAPSHOTS_CHECK));
|
|
165
|
+
}
|
|
166
|
+
saveCheck(raw, durationMs, git, runContext) {
|
|
167
|
+
const snap = {
|
|
168
|
+
...buildMeta(durationMs, git, runContext),
|
|
169
|
+
kind: "check",
|
|
170
|
+
raw
|
|
171
|
+
};
|
|
172
|
+
const history = this.loadCheckHistory();
|
|
173
|
+
history.push(snap);
|
|
174
|
+
while (history.length > this.maxEntries) {
|
|
175
|
+
history.shift();
|
|
176
|
+
}
|
|
177
|
+
writeJson(join(this.rootDir, PATHS.SNAPSHOTS_CHECK), history);
|
|
178
|
+
return snap;
|
|
179
|
+
}
|
|
180
|
+
latestCheck() {
|
|
181
|
+
const h = this.loadCheckHistory();
|
|
182
|
+
return h.length > 0 ? h[h.length - 1] : null;
|
|
183
|
+
}
|
|
184
|
+
// ── Stats snapshots ────────────────────────────────────────────────────────
|
|
185
|
+
loadStatsHistory() {
|
|
186
|
+
return readJson(join(this.rootDir, PATHS.SNAPSHOTS_STATS));
|
|
187
|
+
}
|
|
188
|
+
saveStats(raw, durationMs, git, runContext) {
|
|
189
|
+
const snap = {
|
|
190
|
+
...buildMeta(durationMs, git, runContext),
|
|
191
|
+
kind: "stats",
|
|
192
|
+
raw
|
|
193
|
+
};
|
|
194
|
+
const history = this.loadStatsHistory();
|
|
195
|
+
history.push(snap);
|
|
196
|
+
while (history.length > this.maxEntries) {
|
|
197
|
+
history.shift();
|
|
198
|
+
}
|
|
199
|
+
writeJson(join(this.rootDir, PATHS.SNAPSHOTS_STATS), history);
|
|
200
|
+
return snap;
|
|
201
|
+
}
|
|
202
|
+
latestStats() {
|
|
203
|
+
const h = this.loadStatsHistory();
|
|
204
|
+
return h.length > 0 ? h[h.length - 1] : null;
|
|
205
|
+
}
|
|
206
|
+
// ── Gate snapshots ─────────────────────────────────────────────────────────
|
|
207
|
+
loadGateHistory() {
|
|
208
|
+
return readJson(join(this.rootDir, PATHS.SNAPSHOTS_GATE));
|
|
209
|
+
}
|
|
210
|
+
saveGate(raw, durationMs, git, runContext) {
|
|
211
|
+
const snap = {
|
|
212
|
+
...buildMeta(durationMs, git, runContext),
|
|
213
|
+
kind: "gate",
|
|
214
|
+
raw
|
|
215
|
+
};
|
|
216
|
+
const history = this.loadGateHistory();
|
|
217
|
+
history.push(snap);
|
|
218
|
+
while (history.length > this.maxEntries) {
|
|
219
|
+
history.shift();
|
|
602
220
|
}
|
|
221
|
+
writeJson(join(this.rootDir, PATHS.SNAPSHOTS_GATE), history);
|
|
222
|
+
return snap;
|
|
603
223
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
for (const pkg of packages) {
|
|
610
|
-
const tsconfigPath = join(pkg.dir, "tsconfig.json");
|
|
611
|
-
if (!existsSync(tsconfigPath)) {
|
|
612
|
-
result.skipped.push(pkg.name);
|
|
613
|
-
onProgress?.(pkg.name, "skip");
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
616
|
-
const startMs = Date.now();
|
|
617
|
-
try {
|
|
618
|
-
execSync("pnpm exec tsc --noEmit", {
|
|
619
|
-
cwd: pkg.dir,
|
|
620
|
-
encoding: "utf-8",
|
|
621
|
-
timeout: 12e4,
|
|
622
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
623
|
-
});
|
|
624
|
-
result.passed.push(pkg.name);
|
|
625
|
-
onProgress?.(pkg.name, "pass", Date.now() - startMs);
|
|
626
|
-
} catch (err) {
|
|
627
|
-
result.failed.push(pkg.name);
|
|
628
|
-
const spawnErr = err;
|
|
629
|
-
const rawErr = (spawnErr.stdout || spawnErr.stderr || spawnErr.message || "").trim();
|
|
630
|
-
result.errors[pkg.name] = rawErr.slice(0, 2e3) || `Type check failed (exit code ${spawnErr.status ?? 1})`;
|
|
631
|
-
onProgress?.(pkg.name, "fail", Date.now() - startMs);
|
|
224
|
+
// ── Baseline ───────────────────────────────────────────────────────────────
|
|
225
|
+
loadBaseline() {
|
|
226
|
+
const p = join(this.rootDir, PATHS.BASELINE);
|
|
227
|
+
if (!existsSync(p)) {
|
|
228
|
+
return null;
|
|
632
229
|
}
|
|
633
|
-
}
|
|
634
|
-
return result;
|
|
635
|
-
}
|
|
636
|
-
function runTestCheck(options) {
|
|
637
|
-
const { packages, onProgress } = options;
|
|
638
|
-
const result = { passed: [], failed: [], skipped: [], errors: {} };
|
|
639
|
-
for (const pkg of packages) {
|
|
640
|
-
let pkgJson;
|
|
641
230
|
try {
|
|
642
|
-
|
|
643
|
-
readFileSync(join(pkg.dir, "package.json"), "utf-8")
|
|
644
|
-
);
|
|
231
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
645
232
|
} catch {
|
|
646
|
-
|
|
647
|
-
onProgress?.(pkg.name, "skip");
|
|
648
|
-
continue;
|
|
649
|
-
}
|
|
650
|
-
if (!pkgJson.scripts?.test) {
|
|
651
|
-
result.skipped.push(pkg.name);
|
|
652
|
-
onProgress?.(pkg.name, "skip");
|
|
653
|
-
continue;
|
|
233
|
+
return null;
|
|
654
234
|
}
|
|
655
|
-
const startMs = Date.now();
|
|
656
|
-
try {
|
|
657
|
-
execSync("pnpm run test", {
|
|
658
|
-
cwd: pkg.dir,
|
|
659
|
-
encoding: "utf-8",
|
|
660
|
-
timeout: 12e4,
|
|
661
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
662
|
-
});
|
|
663
|
-
result.passed.push(pkg.name);
|
|
664
|
-
onProgress?.(pkg.name, "pass", Date.now() - startMs);
|
|
665
|
-
} catch (err) {
|
|
666
|
-
result.failed.push(pkg.name);
|
|
667
|
-
const spawnErr = err;
|
|
668
|
-
const rawErr = (spawnErr.stdout || spawnErr.stderr || spawnErr.message || "").trim();
|
|
669
|
-
result.errors[pkg.name] = rawErr.slice(0, 2e3) || `Test failed (exit code ${spawnErr.status ?? 1})`;
|
|
670
|
-
onProgress?.(pkg.name, "fail", Date.now() - startMs);
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
return result;
|
|
674
|
-
}
|
|
675
|
-
function saveLastRun(rootDir, results, packages, submodules) {
|
|
676
|
-
const filePath = join(rootDir, PATHS.LAST_RUN);
|
|
677
|
-
const dir = dirname(filePath);
|
|
678
|
-
if (!existsSync(dir)) {
|
|
679
|
-
mkdirSync(dir, { recursive: true });
|
|
680
235
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
})),
|
|
691
|
-
submodules
|
|
692
|
-
};
|
|
693
|
-
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
694
|
-
}
|
|
695
|
-
function loadLastRun(rootDir) {
|
|
696
|
-
const filePath = join(rootDir, PATHS.LAST_RUN);
|
|
697
|
-
if (!existsSync(filePath)) {
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
702
|
-
} catch {
|
|
703
|
-
return null;
|
|
236
|
+
saveBaseline(check, stats, git) {
|
|
237
|
+
const baseline = {
|
|
238
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
239
|
+
...git ? { git } : {},
|
|
240
|
+
check,
|
|
241
|
+
stats
|
|
242
|
+
};
|
|
243
|
+
writeJson(join(this.rootDir, PATHS.BASELINE), baseline);
|
|
244
|
+
return baseline;
|
|
704
245
|
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// src/runner/qa-orchestrator.ts
|
|
708
|
-
var SKIP_ALIASES = {
|
|
709
|
-
types: "typecheck",
|
|
710
|
-
"type-check": "typecheck",
|
|
711
|
-
tests: "test"
|
|
712
246
|
};
|
|
713
|
-
function
|
|
714
|
-
|
|
715
|
-
if (!skipSet.has("build")) {
|
|
716
|
-
results.build = runBuildCheck({
|
|
717
|
-
packages,
|
|
718
|
-
noCache,
|
|
719
|
-
onProgress: (pkg, status, durationMs) => options.onProgress?.("build", pkg, status, durationMs)
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
if (!skipSet.has("lint")) {
|
|
723
|
-
results.lint = runLintCheck({
|
|
724
|
-
packages,
|
|
725
|
-
onProgress: (pkg, status, durationMs) => options.onProgress?.("lint", pkg, status, durationMs)
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
if (!skipSet.has("typecheck")) {
|
|
729
|
-
results.typeCheck = runTypeCheck({
|
|
730
|
-
packages,
|
|
731
|
-
onProgress: (pkg, status, durationMs) => options.onProgress?.("typeCheck", pkg, status, durationMs)
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
if (!skipSet.has("test")) {
|
|
735
|
-
results.test = runTestCheck({
|
|
736
|
-
packages,
|
|
737
|
-
onProgress: (pkg, status, durationMs) => options.onProgress?.("test", pkg, status, durationMs)
|
|
738
|
-
});
|
|
739
|
-
}
|
|
247
|
+
function extractFailed(snap, task) {
|
|
248
|
+
return snap.raw.results.filter((r) => r.Task === task && !r.OK && !r.Cached).map((r) => r.Package);
|
|
740
249
|
}
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
const
|
|
744
|
-
(
|
|
745
|
-
|
|
746
|
-
const filter = { package: options.package, repo: options.repo, scope: options.scope };
|
|
747
|
-
const packages = getWorkspacePackages(rootDir, filter, options.packagesConfig);
|
|
748
|
-
let cache = noCache ? {} : loadCache(rootDir);
|
|
749
|
-
const results = {};
|
|
750
|
-
if (options.checks && options.checks.length > 0) {
|
|
751
|
-
const activeChecks = skipSet.size > 0 ? options.checks.filter((c) => !skipSet.has(c.id.toLowerCase())) : options.checks;
|
|
752
|
-
Object.assign(results, runCustomChecks(
|
|
753
|
-
activeChecks,
|
|
754
|
-
packages,
|
|
755
|
-
rootDir,
|
|
756
|
-
(checkId, pkg, status, durationMs) => {
|
|
757
|
-
options.onProgress?.(checkId, pkg, status, durationMs);
|
|
758
|
-
}
|
|
759
|
-
));
|
|
760
|
-
} else {
|
|
761
|
-
runBuiltinChecks(options, packages, skipSet, results);
|
|
762
|
-
}
|
|
763
|
-
if (!noCache) {
|
|
764
|
-
for (const pkg of packages) {
|
|
765
|
-
cache = updateCacheEntry(pkg.dir, pkg.name, cache);
|
|
766
|
-
}
|
|
767
|
-
saveCache(rootDir, cache);
|
|
768
|
-
}
|
|
769
|
-
const submodules = {};
|
|
770
|
-
for (const pkg of packages) {
|
|
771
|
-
if (pkg.submodule && !submodules[pkg.repo]) {
|
|
772
|
-
submodules[pkg.repo] = pkg.submodule;
|
|
250
|
+
function collectTasks(snapshots) {
|
|
251
|
+
const s = /* @__PURE__ */ new Set();
|
|
252
|
+
for (const snap of snapshots) {
|
|
253
|
+
for (const r of snap.raw.results) {
|
|
254
|
+
s.add(r.Task);
|
|
773
255
|
}
|
|
774
256
|
}
|
|
775
|
-
|
|
776
|
-
return { results, packages };
|
|
777
|
-
}
|
|
778
|
-
function loadBaseline(rootDir) {
|
|
779
|
-
const path = join(rootDir, PATHS.BASELINE);
|
|
780
|
-
if (!existsSync(path)) {
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
try {
|
|
784
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
785
|
-
} catch {
|
|
786
|
-
return null;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
function saveBaseline(rootDir, snapshot) {
|
|
790
|
-
const path = join(rootDir, PATHS.BASELINE);
|
|
791
|
-
const dir = dirname(path);
|
|
792
|
-
if (!existsSync(dir)) {
|
|
793
|
-
mkdirSync(dir, { recursive: true });
|
|
794
|
-
}
|
|
795
|
-
writeFileSync(path, JSON.stringify(snapshot, null, 2));
|
|
796
|
-
}
|
|
797
|
-
function getGitInfo(rootDir) {
|
|
798
|
-
try {
|
|
799
|
-
const commit = execSync("git rev-parse --short HEAD", {
|
|
800
|
-
cwd: rootDir,
|
|
801
|
-
encoding: "utf-8"
|
|
802
|
-
}).trim();
|
|
803
|
-
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
804
|
-
cwd: rootDir,
|
|
805
|
-
encoding: "utf-8"
|
|
806
|
-
}).trim();
|
|
807
|
-
return { commit, branch };
|
|
808
|
-
} catch {
|
|
809
|
-
return { commit: "unknown", branch: "unknown" };
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
function createBaselineFromResults(results, rootDir) {
|
|
813
|
-
const git = getGitInfo(rootDir);
|
|
814
|
-
const snapshot = {
|
|
815
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
816
|
-
git,
|
|
817
|
-
results: {}
|
|
818
|
-
};
|
|
819
|
-
for (const ct of Object.keys(results)) {
|
|
820
|
-
const r = results[ct];
|
|
821
|
-
snapshot.results[ct] = {
|
|
822
|
-
passed: r.passed.length,
|
|
823
|
-
failed: r.failed.length,
|
|
824
|
-
failedPackages: [...r.failed]
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
return snapshot;
|
|
828
|
-
}
|
|
829
|
-
async function captureBaseline(rootDir) {
|
|
830
|
-
const { results } = await runQA({ rootDir });
|
|
831
|
-
const snapshot = createBaselineFromResults(results, rootDir);
|
|
832
|
-
saveBaseline(rootDir, snapshot);
|
|
833
|
-
return snapshot;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// src/baseline/baseline-comparator.ts
|
|
837
|
-
function compareWithBaseline(results, baseline) {
|
|
838
|
-
const diff = {};
|
|
839
|
-
const checkTypes = [.../* @__PURE__ */ new Set([...Object.keys(results), ...Object.keys(baseline.results)])];
|
|
840
|
-
for (const ct of checkTypes) {
|
|
841
|
-
const current = new Set(results[ct]?.failed ?? []);
|
|
842
|
-
const baselineFailed = new Set(baseline.results[ct]?.failedPackages ?? []);
|
|
843
|
-
const newFailures = [...current].filter((p) => !baselineFailed.has(p));
|
|
844
|
-
const fixed = [...baselineFailed].filter((p) => !current.has(p));
|
|
845
|
-
const stillFailing = [...current].filter((p) => baselineFailed.has(p));
|
|
846
|
-
diff[ct] = {
|
|
847
|
-
newFailures,
|
|
848
|
-
fixed,
|
|
849
|
-
stillFailing,
|
|
850
|
-
delta: current.size - baselineFailed.size
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
return diff;
|
|
854
|
-
}
|
|
855
|
-
function trendPath(rootDir, checkId) {
|
|
856
|
-
return join(rootDir, QA_DATA_DIR, "trends", `${checkId}.json`);
|
|
857
|
-
}
|
|
858
|
-
function loadTrendHistory(rootDir, checkId) {
|
|
859
|
-
const path = trendPath(rootDir, checkId);
|
|
860
|
-
if (!existsSync(path)) {
|
|
861
|
-
return [];
|
|
862
|
-
}
|
|
863
|
-
try {
|
|
864
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
865
|
-
} catch {
|
|
866
|
-
return [];
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
function appendTrendEntry(rootDir, checkId, entry) {
|
|
870
|
-
const path = trendPath(rootDir, checkId);
|
|
871
|
-
const dir = dirname(path);
|
|
872
|
-
if (!existsSync(dir)) {
|
|
873
|
-
mkdirSync(dir, { recursive: true });
|
|
874
|
-
}
|
|
875
|
-
const entries = loadTrendHistory(rootDir, checkId);
|
|
876
|
-
entries.push(entry);
|
|
877
|
-
while (entries.length > HISTORY_MAX_ENTRIES) {
|
|
878
|
-
entries.shift();
|
|
879
|
-
}
|
|
880
|
-
writeFileSync(path, JSON.stringify(entries, null, 2));
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// src/history/history-store.ts
|
|
884
|
-
function loadHistory(rootDir) {
|
|
885
|
-
const path = join(rootDir, PATHS.HISTORY);
|
|
886
|
-
if (!existsSync(path)) {
|
|
887
|
-
return [];
|
|
888
|
-
}
|
|
889
|
-
try {
|
|
890
|
-
return JSON.parse(readFileSync(path, "utf-8"));
|
|
891
|
-
} catch {
|
|
892
|
-
return [];
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
function saveHistory(rootDir, entries) {
|
|
896
|
-
const path = join(rootDir, PATHS.HISTORY);
|
|
897
|
-
const dir = dirname(path);
|
|
898
|
-
if (!existsSync(dir)) {
|
|
899
|
-
mkdirSync(dir, { recursive: true });
|
|
900
|
-
}
|
|
901
|
-
writeFileSync(path, JSON.stringify(entries, null, 2));
|
|
257
|
+
return [...s];
|
|
902
258
|
}
|
|
903
|
-
function
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
summary[ct] = {
|
|
919
|
-
passed: r.passed.length,
|
|
920
|
-
failed: r.failed.length,
|
|
921
|
-
skipped: r.skipped.length
|
|
259
|
+
function buildTimeSeries(snapshots, task) {
|
|
260
|
+
return snapshots.map((snap) => {
|
|
261
|
+
const results = snap.raw.results.filter((r) => r.Task === task);
|
|
262
|
+
const failed = results.filter((r) => !r.OK && !r.Cached).length;
|
|
263
|
+
const cached = results.filter((r) => r.Cached).length;
|
|
264
|
+
const passed = results.filter((r) => r.OK && !r.Cached).length;
|
|
265
|
+
return {
|
|
266
|
+
timestamp: snap.timestamp,
|
|
267
|
+
gitCommit: snap.git?.commit ?? "unknown",
|
|
268
|
+
gitBranch: snap.git?.branch ?? "unknown",
|
|
269
|
+
gitMessage: snap.git?.message ?? "",
|
|
270
|
+
failed,
|
|
271
|
+
cached,
|
|
272
|
+
passed,
|
|
273
|
+
total: results.length
|
|
922
274
|
};
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
let submodules;
|
|
926
|
-
if (packages) {
|
|
927
|
-
const subs = {};
|
|
928
|
-
for (const pkg of packages) {
|
|
929
|
-
if (pkg.submodule && !subs[pkg.repo]) {
|
|
930
|
-
subs[pkg.repo] = pkg.submodule;
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
if (Object.keys(subs).length > 0) {
|
|
934
|
-
submodules = subs;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
return {
|
|
938
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
939
|
-
git: { commit, branch, message },
|
|
940
|
-
submodules,
|
|
941
|
-
status: hasFailures ? "failed" : "passed",
|
|
942
|
-
summary,
|
|
943
|
-
failedPackages,
|
|
944
|
-
...runContext ? { runContext } : {}
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
function appendEntry(rootDir, entry, checks) {
|
|
948
|
-
const history = loadHistory(rootDir);
|
|
949
|
-
history.push(entry);
|
|
950
|
-
while (history.length > HISTORY_MAX_ENTRIES) {
|
|
951
|
-
history.shift();
|
|
952
|
-
}
|
|
953
|
-
saveHistory(rootDir, history);
|
|
954
|
-
if (checks) {
|
|
955
|
-
for (const check of checks) {
|
|
956
|
-
if (check.trending && entry.summary[check.id] !== void 0) {
|
|
957
|
-
appendTrendEntry(rootDir, check.id, entry);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
275
|
+
});
|
|
961
276
|
}
|
|
962
|
-
function
|
|
963
|
-
const
|
|
964
|
-
for (
|
|
965
|
-
|
|
966
|
-
|
|
277
|
+
function buildChangelog(snapshots, task) {
|
|
278
|
+
const changelog = [];
|
|
279
|
+
for (let i = 1; i < snapshots.length; i++) {
|
|
280
|
+
const prev = snapshots[i - 1];
|
|
281
|
+
const curr = snapshots[i];
|
|
282
|
+
const prevFailed = new Set(extractFailed(prev, task));
|
|
283
|
+
const currFailed = extractFailed(curr, task);
|
|
284
|
+
const currFailedSet = new Set(currFailed);
|
|
285
|
+
const newFailures = currFailed.filter((p) => !prevFailed.has(p));
|
|
286
|
+
const fixed = [...prevFailed].filter((p) => !currFailedSet.has(p));
|
|
287
|
+
const delta = currFailed.length - prevFailed.size;
|
|
288
|
+
if (newFailures.length > 0 || fixed.length > 0) {
|
|
289
|
+
changelog.push({
|
|
290
|
+
timestamp: curr.timestamp,
|
|
291
|
+
gitCommit: curr.git?.commit ?? "unknown",
|
|
292
|
+
gitMessage: curr.git?.message ?? "",
|
|
293
|
+
newFailures,
|
|
294
|
+
fixed,
|
|
295
|
+
delta
|
|
296
|
+
});
|
|
967
297
|
}
|
|
968
298
|
}
|
|
969
|
-
return
|
|
299
|
+
return changelog;
|
|
970
300
|
}
|
|
971
301
|
function analyzeTrends(history, window = TRENDS_WINDOW) {
|
|
972
302
|
if (history.length < 2) {
|
|
973
|
-
return [];
|
|
974
|
-
}
|
|
975
|
-
const
|
|
976
|
-
const first =
|
|
977
|
-
const last =
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
if (delta > 0) {
|
|
985
|
-
trend = "regression";
|
|
986
|
-
} else if (delta < 0) {
|
|
987
|
-
trend = "improvement";
|
|
988
|
-
} else {
|
|
989
|
-
trend = "no-change";
|
|
990
|
-
}
|
|
991
|
-
results.push({ checkType: ct, label: getCheckLabel(ct), icon: getCheckIcon(ct), previous, current, delta, trend });
|
|
992
|
-
}
|
|
993
|
-
return results;
|
|
994
|
-
}
|
|
995
|
-
function analyzeEnrichedTrends(history, window = TRENDS_WINDOW) {
|
|
996
|
-
if (history.length < 2) {
|
|
997
|
-
return [];
|
|
998
|
-
}
|
|
999
|
-
const windowEntries = history.slice(-window);
|
|
1000
|
-
const first = windowEntries[0];
|
|
1001
|
-
const last = windowEntries[windowEntries.length - 1];
|
|
1002
|
-
const results = [];
|
|
1003
|
-
for (const ct of collectCheckTypes(windowEntries)) {
|
|
1004
|
-
const timeSeries = windowEntries.map((entry) => ({
|
|
1005
|
-
timestamp: entry.timestamp,
|
|
1006
|
-
gitCommit: entry.git.commit,
|
|
1007
|
-
gitBranch: entry.git.branch,
|
|
1008
|
-
gitMessage: entry.git.message,
|
|
1009
|
-
passed: entry.summary[ct]?.passed ?? 0,
|
|
1010
|
-
failed: entry.summary[ct]?.failed ?? 0,
|
|
1011
|
-
skipped: entry.summary[ct]?.skipped ?? 0
|
|
1012
|
-
}));
|
|
1013
|
-
const changelog = [];
|
|
1014
|
-
const deltas = [];
|
|
1015
|
-
for (let i = 1; i < windowEntries.length; i++) {
|
|
1016
|
-
const prev = windowEntries[i - 1];
|
|
1017
|
-
const curr = windowEntries[i];
|
|
1018
|
-
const prevFailed = new Set(prev.failedPackages[ct] ?? []);
|
|
1019
|
-
const currFailed = curr.failedPackages[ct] ?? [];
|
|
1020
|
-
const currFailedSet = new Set(currFailed);
|
|
1021
|
-
const newFailures = currFailed.filter((p) => !prevFailed.has(p));
|
|
1022
|
-
const fixed = [...prevFailed].filter((p) => !currFailedSet.has(p));
|
|
1023
|
-
const delta2 = currFailed.length - prevFailed.size;
|
|
1024
|
-
deltas.push(delta2);
|
|
1025
|
-
if (newFailures.length > 0 || fixed.length > 0) {
|
|
1026
|
-
changelog.push({
|
|
1027
|
-
timestamp: curr.timestamp,
|
|
1028
|
-
gitCommit: curr.git.commit,
|
|
1029
|
-
gitMessage: curr.git.message,
|
|
1030
|
-
newFailures,
|
|
1031
|
-
fixed,
|
|
1032
|
-
delta: delta2
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
const previous = first.summary[ct]?.failed ?? 0;
|
|
1037
|
-
const current = last.summary[ct]?.failed ?? 0;
|
|
303
|
+
return { window, historyCount: history.length, tasks: [] };
|
|
304
|
+
}
|
|
305
|
+
const windowSnaps = history.slice(-window);
|
|
306
|
+
const first = windowSnaps[0];
|
|
307
|
+
const last = windowSnaps[windowSnaps.length - 1];
|
|
308
|
+
const tasks = collectTasks(windowSnaps);
|
|
309
|
+
const taskTrends = tasks.map((task) => {
|
|
310
|
+
const timeSeries = buildTimeSeries(windowSnaps, task);
|
|
311
|
+
const changelog = buildChangelog(windowSnaps, task);
|
|
312
|
+
const previous = extractFailed(first, task).length;
|
|
313
|
+
const current = extractFailed(last, task).length;
|
|
1038
314
|
const delta = current - previous;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
trend = "no-change";
|
|
1046
|
-
}
|
|
1047
|
-
const velocity = deltas.length > 0 ? deltas.reduce((sum, d) => sum + d, 0) / deltas.length : 0;
|
|
1048
|
-
results.push({
|
|
1049
|
-
checkType: ct,
|
|
1050
|
-
label: getCheckLabel(ct),
|
|
1051
|
-
icon: getCheckIcon(ct),
|
|
1052
|
-
timeSeries,
|
|
1053
|
-
changelog,
|
|
1054
|
-
current,
|
|
1055
|
-
previous,
|
|
1056
|
-
delta,
|
|
1057
|
-
trend,
|
|
1058
|
-
velocity: Math.round(velocity * 100) / 100
|
|
1059
|
-
});
|
|
1060
|
-
}
|
|
1061
|
-
return results;
|
|
315
|
+
const deltas = changelog.map((c) => c.delta);
|
|
316
|
+
const velocity = deltas.length > 0 ? Math.round(deltas.reduce((s, d) => s + d, 0) / deltas.length * 100) / 100 : 0;
|
|
317
|
+
const direction = delta > 0 ? "regression" : delta < 0 ? "improvement" : "no-change";
|
|
318
|
+
return { task, previous, current, delta, direction, velocity, timeSeries, changelog };
|
|
319
|
+
});
|
|
320
|
+
return { window, historyCount: history.length, tasks: taskTrends };
|
|
1062
321
|
}
|
|
1063
322
|
|
|
1064
|
-
// src/
|
|
323
|
+
// src/analysis/regression-detector.ts
|
|
1065
324
|
function detectRegressions(history) {
|
|
1066
325
|
if (history.length < 2) {
|
|
1067
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
hasRegressions: false,
|
|
328
|
+
regressions: [],
|
|
329
|
+
comparedAt: { previous: "", current: "" }
|
|
330
|
+
};
|
|
1068
331
|
}
|
|
1069
|
-
const
|
|
1070
|
-
const
|
|
332
|
+
const prev = history[history.length - 2];
|
|
333
|
+
const curr = history[history.length - 1];
|
|
334
|
+
const allTasks = /* @__PURE__ */ new Set([
|
|
335
|
+
...prev.raw.results.map((r) => r.Task),
|
|
336
|
+
...curr.raw.results.map((r) => r.Task)
|
|
337
|
+
]);
|
|
1071
338
|
const regressions = [];
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
339
|
+
for (const task of allTasks) {
|
|
340
|
+
const prevFailed = new Set(
|
|
341
|
+
prev.raw.results.filter((r) => r.Task === task && !r.OK && !r.Cached).map((r) => r.Package)
|
|
342
|
+
);
|
|
343
|
+
const currFailed = curr.raw.results.filter((r) => r.Task === task && !r.OK && !r.Cached).map((r) => r.Package);
|
|
1076
344
|
const newFailures = currFailed.filter((p) => !prevFailed.has(p));
|
|
1077
|
-
const delta = currFailed.length - prevFailed.size;
|
|
1078
345
|
if (newFailures.length > 0) {
|
|
1079
346
|
regressions.push({
|
|
1080
|
-
|
|
1081
|
-
delta,
|
|
347
|
+
task,
|
|
348
|
+
delta: currFailed.length - prevFailed.size,
|
|
1082
349
|
newFailures
|
|
1083
350
|
});
|
|
1084
351
|
}
|
|
1085
352
|
}
|
|
1086
353
|
return {
|
|
1087
354
|
hasRegressions: regressions.length > 0,
|
|
1088
|
-
regressions
|
|
355
|
+
regressions,
|
|
356
|
+
comparedAt: { previous: prev.timestamp, current: curr.timestamp }
|
|
1089
357
|
};
|
|
1090
358
|
}
|
|
1091
359
|
|
|
1092
|
-
// src/
|
|
1093
|
-
function
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
found = true;
|
|
1109
|
-
} else {
|
|
1110
|
-
checks[ct] = "skipped";
|
|
1111
|
-
}
|
|
360
|
+
// src/analysis/baseline-comparator.ts
|
|
361
|
+
function issueKey(pkg, check, message) {
|
|
362
|
+
return `${pkg}::${check}::${message}`;
|
|
363
|
+
}
|
|
364
|
+
function compareWithBaseline(currentCheck, currentStats, baseline) {
|
|
365
|
+
const baselineIssues = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const [pkg, pkgData] of Object.entries(baseline.check.packages)) {
|
|
367
|
+
for (const issue of pkgData.issues ?? []) {
|
|
368
|
+
const key = issueKey(pkg, issue.check, issue.message);
|
|
369
|
+
baselineIssues.set(key, {
|
|
370
|
+
pkg,
|
|
371
|
+
check: issue.check,
|
|
372
|
+
status: "fixed",
|
|
373
|
+
severity: issue.severity,
|
|
374
|
+
message: issue.message
|
|
375
|
+
});
|
|
1112
376
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
377
|
+
}
|
|
378
|
+
const currentIssues = /* @__PURE__ */ new Map();
|
|
379
|
+
for (const [pkg, pkgData] of Object.entries(currentCheck.packages)) {
|
|
380
|
+
for (const issue of pkgData.issues ?? []) {
|
|
381
|
+
const key = issueKey(pkg, issue.check, issue.message);
|
|
382
|
+
currentIssues.set(key, {
|
|
383
|
+
pkg,
|
|
384
|
+
check: issue.check,
|
|
385
|
+
status: "new",
|
|
386
|
+
severity: issue.severity,
|
|
387
|
+
message: issue.message
|
|
388
|
+
});
|
|
1115
389
|
}
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
}
|
|
390
|
+
}
|
|
391
|
+
const newIssues = [];
|
|
392
|
+
const fixedIssues = [];
|
|
393
|
+
const persistingIssues = [];
|
|
394
|
+
for (const [key, issue] of currentIssues) {
|
|
395
|
+
if (baselineIssues.has(key)) {
|
|
396
|
+
persistingIssues.push({ ...issue, status: "persisting" });
|
|
397
|
+
} else {
|
|
398
|
+
newIssues.push({ ...issue, status: "new" });
|
|
1126
399
|
}
|
|
1127
|
-
entries.push({
|
|
1128
|
-
timestamp: h.timestamp,
|
|
1129
|
-
git: h.git,
|
|
1130
|
-
submoduleCommit,
|
|
1131
|
-
checks
|
|
1132
|
-
});
|
|
1133
400
|
}
|
|
1134
|
-
|
|
401
|
+
for (const [key, issue] of baselineIssues) {
|
|
402
|
+
if (!currentIssues.has(key)) {
|
|
403
|
+
fixedIssues.push({ ...issue, status: "fixed" });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
newIssues,
|
|
408
|
+
fixedIssues,
|
|
409
|
+
persistingIssues,
|
|
410
|
+
newIssueCount: newIssues.length,
|
|
411
|
+
fixedIssueCount: fixedIssues.length,
|
|
412
|
+
scoreDelta: currentStats.score - baseline.stats.score,
|
|
413
|
+
gradeDelta: `${baseline.stats.grade} \u2192 ${currentStats.grade}`
|
|
414
|
+
};
|
|
1135
415
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
416
|
+
|
|
417
|
+
// src/analysis/package-timeline.ts
|
|
418
|
+
function buildPackageTimeline(history, packageName) {
|
|
419
|
+
const entries = [];
|
|
420
|
+
for (const snap of [...history].reverse()) {
|
|
421
|
+
for (const r of snap.raw.results.filter((r2) => r2.Package === packageName)) {
|
|
422
|
+
entries.push({
|
|
423
|
+
timestamp: snap.timestamp,
|
|
424
|
+
gitCommit: snap.git?.commit ?? "unknown",
|
|
425
|
+
task: r.Task,
|
|
426
|
+
status: r.Cached ? "cached" : r.OK ? "passed" : "failed"
|
|
427
|
+
});
|
|
1141
428
|
}
|
|
1142
429
|
}
|
|
1143
|
-
const
|
|
430
|
+
const taskNames = [...new Set(entries.map((e) => e.task))];
|
|
1144
431
|
let totalFlips = 0;
|
|
1145
432
|
let totalTransitions = 0;
|
|
1146
|
-
|
|
433
|
+
const flakyTasks = [];
|
|
434
|
+
for (const task of taskNames) {
|
|
435
|
+
const taskEntries = entries.filter((e) => e.task === task && e.status !== "cached");
|
|
1147
436
|
let flips = 0;
|
|
1148
|
-
let
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
const curr = entries[i].checks[ct];
|
|
1152
|
-
if (prev === "skipped" || curr === "skipped" || !prev || !curr) {
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
transitions++;
|
|
1156
|
-
if (prev !== curr) {
|
|
437
|
+
for (let i = 1; i < taskEntries.length; i++) {
|
|
438
|
+
totalTransitions++;
|
|
439
|
+
if (taskEntries[i].status !== taskEntries[i - 1].status) {
|
|
1157
440
|
flips++;
|
|
441
|
+
totalFlips++;
|
|
1158
442
|
}
|
|
1159
443
|
}
|
|
1160
|
-
if (
|
|
1161
|
-
|
|
444
|
+
if (taskEntries.length > 1 && flips / (taskEntries.length - 1) > 0.3) {
|
|
445
|
+
flakyTasks.push(task);
|
|
1162
446
|
}
|
|
1163
|
-
totalFlips += flips;
|
|
1164
|
-
totalTransitions += transitions;
|
|
1165
447
|
}
|
|
1166
|
-
|
|
1167
|
-
flakyScore: totalTransitions > 0 ? Math.min(1, totalFlips / totalTransitions) : 0,
|
|
1168
|
-
flakyChecks
|
|
1169
|
-
};
|
|
1170
|
-
}
|
|
1171
|
-
function computeStreak(entries) {
|
|
448
|
+
const flakyScore = totalTransitions > 0 ? Math.round(totalFlips / totalTransitions * 100) / 100 : 0;
|
|
1172
449
|
const latest = entries[0];
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
450
|
+
let streakStatus = "passing";
|
|
451
|
+
let streakCount = 0;
|
|
452
|
+
if (latest) {
|
|
453
|
+
streakStatus = latest.status === "failed" ? "failing" : "passing";
|
|
454
|
+
streakCount = 1;
|
|
455
|
+
for (let i = 1; i < entries.length; i++) {
|
|
456
|
+
const s = entries[i].status === "failed" ? "failing" : "passing";
|
|
457
|
+
if (s !== streakStatus) {
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
streakCount++;
|
|
1182
461
|
}
|
|
1183
|
-
count++;
|
|
1184
462
|
}
|
|
1185
|
-
return { status: streakStatus, count };
|
|
1186
|
-
}
|
|
1187
|
-
function getPackageTimeline(history, packageName) {
|
|
1188
|
-
const { entries, repo } = buildEntries(history, packageName);
|
|
1189
|
-
const { flakyScore, flakyChecks } = computeFlakyScore(entries);
|
|
1190
463
|
let firstFailure;
|
|
1191
|
-
for (
|
|
1192
|
-
if (
|
|
1193
|
-
firstFailure =
|
|
464
|
+
for (const e of [...entries].reverse()) {
|
|
465
|
+
if (e.status === "failed") {
|
|
466
|
+
firstFailure = e.timestamp;
|
|
467
|
+
break;
|
|
1194
468
|
}
|
|
1195
469
|
}
|
|
1196
470
|
return {
|
|
1197
471
|
packageName,
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
firstFailure
|
|
1203
|
-
currentStreak: computeStreak(entries)
|
|
472
|
+
history: entries,
|
|
473
|
+
flakyScore,
|
|
474
|
+
flakyTasks,
|
|
475
|
+
currentStreak: { status: streakStatus, count: streakCount },
|
|
476
|
+
firstFailure
|
|
1204
477
|
};
|
|
1205
478
|
}
|
|
1206
479
|
|
|
1207
|
-
// src/report/
|
|
1208
|
-
function
|
|
1209
|
-
|
|
1210
|
-
if (checks) {
|
|
1211
|
-
for (const c of checks) {
|
|
1212
|
-
severityMap[c.id] = c.severity ?? "blocker";
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
const hasBlockerFailures = Object.entries(results).some(([ct, r]) => {
|
|
1216
|
-
const sev = severityMap[ct] ?? "blocker";
|
|
1217
|
-
return sev !== "info" && sev !== "warning" && r.failed.length > 0;
|
|
1218
|
-
});
|
|
1219
|
-
const summary = {};
|
|
1220
|
-
const failures = {};
|
|
1221
|
-
const errors = {};
|
|
1222
|
-
const blockers = [];
|
|
1223
|
-
const warnings = [];
|
|
1224
|
-
for (const ct of Object.keys(results)) {
|
|
1225
|
-
const r = results[ct];
|
|
1226
|
-
const total = r.passed.length + r.failed.length + r.skipped.length;
|
|
1227
|
-
summary[ct] = {
|
|
1228
|
-
total,
|
|
1229
|
-
passed: r.passed.length,
|
|
1230
|
-
failed: r.failed.length,
|
|
1231
|
-
skipped: r.skipped.length
|
|
1232
|
-
};
|
|
1233
|
-
failures[ct] = [...r.failed];
|
|
1234
|
-
errors[ct] = { ...r.errors };
|
|
1235
|
-
if (r.failed.length > 0) {
|
|
1236
|
-
const sev = severityMap[ct] ?? "blocker";
|
|
1237
|
-
const items = r.failed.flatMap((target) => {
|
|
1238
|
-
const detailItems = r.details?.[target];
|
|
1239
|
-
if (detailItems && detailItems.length > 0) {
|
|
1240
|
-
return detailItems;
|
|
1241
|
-
}
|
|
1242
|
-
const msg = r.errors[target];
|
|
1243
|
-
return msg ? [{ target, message: msg }] : [{ target, message: `check ${ct} failed` }];
|
|
1244
|
-
});
|
|
1245
|
-
if (sev === "blocker") {
|
|
1246
|
-
blockers.push({ check: ct, items });
|
|
1247
|
-
} else if (sev === "warning") {
|
|
1248
|
-
warnings.push({ check: ct, items });
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
return {
|
|
1253
|
-
status: hasBlockerFailures ? "failed" : "passed",
|
|
1254
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1255
|
-
summary,
|
|
1256
|
-
failures,
|
|
1257
|
-
errors,
|
|
1258
|
-
baseline: diff ?? null,
|
|
1259
|
-
blockers,
|
|
1260
|
-
warnings
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
function buildDetailedJsonReport(results, grouped, diff, checks) {
|
|
1264
|
-
const base = buildJsonReport(results, diff, checks);
|
|
1265
|
-
return { ...base, grouped };
|
|
1266
|
-
}
|
|
1267
|
-
function icon(ct) {
|
|
1268
|
-
return getCheckIcon(ct);
|
|
1269
|
-
}
|
|
1270
|
-
function label(ct) {
|
|
1271
|
-
return getCheckLabel(ct);
|
|
480
|
+
// src/report/text-reporter.ts
|
|
481
|
+
function formatTaskName(task) {
|
|
482
|
+
return task.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
1272
483
|
}
|
|
1273
|
-
function
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
const d = diff[ct];
|
|
1277
|
-
if (d.newFailures.length > 0) {
|
|
1278
|
-
lines.push(`${icon(ct)} ${label(ct)}: +${d.newFailures.length} new failures`);
|
|
1279
|
-
for (const pkg of d.newFailures) {
|
|
1280
|
-
lines.push(` - ${pkg}`);
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
if (d.fixed.length > 0) {
|
|
1284
|
-
lines.push(`${icon(ct)} ${label(ct)}: -${d.fixed.length} fixed`);
|
|
1285
|
-
}
|
|
484
|
+
function buildRunReport(snap) {
|
|
485
|
+
if (!snap) {
|
|
486
|
+
return [{ header: "Run", lines: ["No run data available."] }];
|
|
1286
487
|
}
|
|
1287
|
-
return lines;
|
|
1288
|
-
}
|
|
1289
|
-
function buildRunReport(results, diff) {
|
|
1290
488
|
const sections = [];
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
const
|
|
1297
|
-
const
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
489
|
+
const tasks = [...new Set(snap.raw.results.map((r) => r.Task))];
|
|
490
|
+
for (const task of tasks) {
|
|
491
|
+
const results = snap.raw.results.filter((r) => r.Task === task);
|
|
492
|
+
const passed = results.filter((r) => r.OK && !r.Cached).length;
|
|
493
|
+
const failed = results.filter((r) => !r.OK && !r.Cached).length;
|
|
494
|
+
const cached = results.filter((r) => r.Cached).length;
|
|
495
|
+
const failedPkgs = results.filter((r) => !r.OK && !r.Cached).map((r) => r.Package);
|
|
496
|
+
const lines = [`pass ${passed} fail ${failed} cached ${cached}`];
|
|
497
|
+
if (failedPkgs.length > 0) {
|
|
498
|
+
const shown = failedPkgs.slice(0, 5);
|
|
499
|
+
lines.push(...shown.map((p2) => ` \u2717 ${p2}`));
|
|
500
|
+
if (failedPkgs.length > 5) {
|
|
501
|
+
lines.push(` ... ${failedPkgs.length - 5} more`);
|
|
1304
502
|
}
|
|
1305
|
-
if (r.failed.length > 5) {
|
|
1306
|
-
summaryLines.push(` ... and ${r.failed.length - 5} more`);
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
totalPassed += r.passed.length;
|
|
1310
|
-
totalFailed += r.failed.length;
|
|
1311
|
-
totalSkipped += r.skipped.length;
|
|
1312
|
-
}
|
|
1313
|
-
sections.push({ header: "QA Summary Report", lines: summaryLines });
|
|
1314
|
-
if (diff) {
|
|
1315
|
-
const diffLines = buildBaselineDiffLines(diff);
|
|
1316
|
-
if (diffLines.length > 0) {
|
|
1317
|
-
sections.push({ header: "Baseline Comparison", lines: diffLines });
|
|
1318
503
|
}
|
|
504
|
+
sections.push({ header: formatTaskName(task), lines });
|
|
1319
505
|
}
|
|
506
|
+
const status = snap.raw.ok ? "passed" : "failed";
|
|
507
|
+
const { total, passed: p, failed: f, cached: c } = snap.raw.summary;
|
|
1320
508
|
sections.push({
|
|
1321
|
-
header: "
|
|
1322
|
-
lines: [
|
|
509
|
+
header: "Summary",
|
|
510
|
+
lines: [`${status.toUpperCase()} total ${total} pass ${p} fail ${f} cached ${c} (${snap.raw.elapsed})`]
|
|
1323
511
|
});
|
|
1324
512
|
return sections;
|
|
1325
513
|
}
|
|
1326
|
-
function
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
for (const entry of entries) {
|
|
1330
|
-
const date = new Date(entry.timestamp).toLocaleDateString();
|
|
1331
|
-
const status = entry.status === "passed" ? "PASS" : "FAIL";
|
|
1332
|
-
const summary = Object.keys(entry.summary).map((ct) => {
|
|
1333
|
-
const s = entry.summary[ct];
|
|
1334
|
-
return `${icon(ct)} ${s.failed}F`;
|
|
1335
|
-
}).join(" ");
|
|
1336
|
-
lines.push(`${date} ${entry.git.commit} ${status} ${summary} ${entry.git.message.slice(0, 40)}`);
|
|
1337
|
-
}
|
|
1338
|
-
return [{ header: `QA History (last ${entries.length})`, lines }];
|
|
1339
|
-
}
|
|
1340
|
-
function buildTrendsReport(trends, history) {
|
|
1341
|
-
if (trends.length === 0) {
|
|
1342
|
-
return [{ header: "QA Trends", lines: ["Not enough history (need at least 2 entries)"] }];
|
|
1343
|
-
}
|
|
1344
|
-
const lines = [];
|
|
1345
|
-
for (const t of trends) {
|
|
1346
|
-
const arrow = t.delta > 0 ? `+${t.delta} (regression)` : t.delta < 0 ? `${t.delta} (improvement)` : "\u2192 no change";
|
|
1347
|
-
lines.push(`${icon(t.checkType)} ${label(t.checkType).padEnd(12)} ${t.previous} \u2192 ${t.current} ${arrow}`);
|
|
1348
|
-
}
|
|
1349
|
-
if (history.length >= 2) {
|
|
1350
|
-
const first = history[Math.max(0, history.length - 10)];
|
|
1351
|
-
const last = history[history.length - 1];
|
|
1352
|
-
lines.push("");
|
|
1353
|
-
lines.push(`Period: ${new Date(first.timestamp).toLocaleDateString()} \u2192 ${new Date(last.timestamp).toLocaleDateString()}`);
|
|
1354
|
-
}
|
|
1355
|
-
return [{ header: "QA Trends", lines }];
|
|
1356
|
-
}
|
|
1357
|
-
function buildRegressionsReport(result, history) {
|
|
1358
|
-
if (history.length < 2) {
|
|
1359
|
-
return [{ header: "Regression Detection", lines: ["Not enough history (need at least 2 entries)"] }];
|
|
1360
|
-
}
|
|
1361
|
-
const prev = history[history.length - 2];
|
|
1362
|
-
const curr = history[history.length - 1];
|
|
1363
|
-
const lines = [
|
|
1364
|
-
`Comparing: ${prev.git.commit} \u2192 ${curr.git.commit}`,
|
|
1365
|
-
""
|
|
1366
|
-
];
|
|
1367
|
-
if (!result.hasRegressions) {
|
|
1368
|
-
lines.push("No regressions detected.");
|
|
1369
|
-
return [{ header: "Regression Detection", lines }];
|
|
514
|
+
function buildCheckReport(snap) {
|
|
515
|
+
if (!snap) {
|
|
516
|
+
return [{ header: "Check", lines: ["No check data available."] }];
|
|
1370
517
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
518
|
+
const sections = [];
|
|
519
|
+
for (const [pkg, pkgData] of Object.entries(snap.raw.packages)) {
|
|
520
|
+
const issues = pkgData.issues ?? [];
|
|
521
|
+
if (issues.length === 0) {
|
|
522
|
+
continue;
|
|
1375
523
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
if (!baseline) {
|
|
1383
|
-
return [{ header: "Baseline Status", lines: ["No baseline captured yet. Run baseline:update first."] }];
|
|
1384
|
-
}
|
|
1385
|
-
const lines = [
|
|
1386
|
-
`Captured: ${new Date(baseline.timestamp).toLocaleString()}`,
|
|
1387
|
-
`Git: ${baseline.git.commit} (${baseline.git.branch})`,
|
|
1388
|
-
""
|
|
1389
|
-
];
|
|
1390
|
-
for (const ct of Object.keys(baseline.results)) {
|
|
1391
|
-
const r = baseline.results[ct];
|
|
1392
|
-
lines.push(`${icon(ct)} ${label(ct).padEnd(12)} ${r.passed} passed, ${r.failed} failed`);
|
|
1393
|
-
if (r.failedPackages.length > 0) {
|
|
1394
|
-
const shown = r.failedPackages.slice(0, 3);
|
|
1395
|
-
for (const pkg of shown) {
|
|
1396
|
-
lines.push(` - ${pkg}`);
|
|
1397
|
-
}
|
|
1398
|
-
if (r.failedPackages.length > 3) {
|
|
1399
|
-
lines.push(` ... and ${r.failedPackages.length - 3} more`);
|
|
1400
|
-
}
|
|
524
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
525
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
526
|
+
const infos = issues.filter((i) => i.severity === "info").length;
|
|
527
|
+
const lines = [`errors ${errors} warnings ${warnings} info ${infos}`];
|
|
528
|
+
for (const issue of issues.slice(0, 5)) {
|
|
529
|
+
lines.push(` [${issue.severity}] ${issue.check}: ${issue.message}`);
|
|
1401
530
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
function checkTag(status, ct) {
|
|
1406
|
-
const short = ct === "typeCheck" ? "types" : ct;
|
|
1407
|
-
if (status === "failed") {
|
|
1408
|
-
return short.toUpperCase();
|
|
1409
|
-
}
|
|
1410
|
-
if (status === "skipped") {
|
|
1411
|
-
return `-${short}-`;
|
|
1412
|
-
}
|
|
1413
|
-
return short;
|
|
1414
|
-
}
|
|
1415
|
-
function getErrorPreview(raw) {
|
|
1416
|
-
const errLines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
1417
|
-
for (const el of errLines) {
|
|
1418
|
-
const cleaned = el.replace(/^Command failed: .*/, "").trim();
|
|
1419
|
-
if (cleaned.length > 0) {
|
|
1420
|
-
return cleaned.replace(/\/[^\s]*\/kb-labs\//g, "").slice(0, 100);
|
|
531
|
+
if (issues.length > 5) {
|
|
532
|
+
lines.push(` ... ${issues.length - 5} more`);
|
|
1421
533
|
}
|
|
534
|
+
sections.push({ header: pkg, lines });
|
|
1422
535
|
}
|
|
1423
|
-
|
|
1424
|
-
}
|
|
1425
|
-
function renderPackageLines(pkg, lines) {
|
|
1426
|
-
const hasFail = Object.values(pkg.checks).some((v) => v === "failed");
|
|
1427
|
-
const status = hasFail ? "FAIL" : "PASS";
|
|
1428
|
-
const tags = Object.keys(pkg.checks).map((ct) => checkTag(pkg.checks[ct], ct)).join(" ");
|
|
1429
|
-
lines.push(` ${status} ${pkg.name.padEnd(40)} ${tags}`);
|
|
1430
|
-
if (hasFail) {
|
|
1431
|
-
for (const ct of Object.keys(pkg.checks)) {
|
|
1432
|
-
if (pkg.checks[ct] === "failed") {
|
|
1433
|
-
const preview = getErrorPreview((pkg.errors[ct] ?? "").trim());
|
|
1434
|
-
lines.push(` ${ct}: ${preview || "failed"}`);
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
536
|
+
if (sections.length === 0) {
|
|
537
|
+
sections.push({ header: "Check", lines: ["All packages OK."] });
|
|
1437
538
|
}
|
|
539
|
+
return sections;
|
|
1438
540
|
}
|
|
1439
|
-
function
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
for (const repoKey of Object.keys(cat.repos).sort()) {
|
|
1443
|
-
const repo = cat.repos[repoKey];
|
|
1444
|
-
lines.push(` ${repoKey} (${repo.summary.total} packages)`);
|
|
1445
|
-
const sorted = [...repo.packages].sort((a, b) => {
|
|
1446
|
-
const aFail = Object.values(a.checks).some((v) => v === "failed") ? 0 : 1;
|
|
1447
|
-
const bFail = Object.values(b.checks).some((v) => v === "failed") ? 0 : 1;
|
|
1448
|
-
if (aFail !== bFail) {
|
|
1449
|
-
return aFail - bFail;
|
|
1450
|
-
}
|
|
1451
|
-
return a.name.localeCompare(b.name);
|
|
1452
|
-
});
|
|
1453
|
-
for (const pkg of sorted) {
|
|
1454
|
-
renderPackageLines(pkg, lines);
|
|
1455
|
-
}
|
|
1456
|
-
lines.push("");
|
|
541
|
+
function buildStatsReport(snap) {
|
|
542
|
+
if (!snap) {
|
|
543
|
+
return [{ header: "Stats", lines: ["No stats data available."] }];
|
|
1457
544
|
}
|
|
1458
|
-
|
|
1459
|
-
}
|
|
1460
|
-
function buildDetailedRunReport(grouped, diff) {
|
|
545
|
+
const { score, grade, summary, by_category, coverage } = snap.raw;
|
|
1461
546
|
const sections = [];
|
|
1462
|
-
const categoryKeys = Object.keys(grouped.categories).sort((a, b) => {
|
|
1463
|
-
if (a === "uncategorized") {
|
|
1464
|
-
return 1;
|
|
1465
|
-
}
|
|
1466
|
-
if (b === "uncategorized") {
|
|
1467
|
-
return -1;
|
|
1468
|
-
}
|
|
1469
|
-
return a.localeCompare(b);
|
|
1470
|
-
});
|
|
1471
|
-
for (const catKey of categoryKeys) {
|
|
1472
|
-
const cat = grouped.categories[catKey];
|
|
1473
|
-
sections.push({ header: `${cat.label} (${cat.summary.total} packages)`, lines: renderCategoryLines(catKey, grouped) });
|
|
1474
|
-
}
|
|
1475
|
-
if (diff) {
|
|
1476
|
-
const diffLines = buildBaselineDiffLines(diff);
|
|
1477
|
-
if (diffLines.length > 0) {
|
|
1478
|
-
sections.push({ header: "Baseline Comparison", lines: diffLines });
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
let totalPassed = 0;
|
|
1482
|
-
let totalFailed = 0;
|
|
1483
|
-
for (const catKey of categoryKeys) {
|
|
1484
|
-
totalPassed += grouped.categories[catKey].summary.passed;
|
|
1485
|
-
totalFailed += grouped.categories[catKey].summary.failed;
|
|
1486
|
-
}
|
|
1487
547
|
sections.push({
|
|
1488
|
-
header: "
|
|
1489
|
-
lines: [
|
|
548
|
+
header: "Score",
|
|
549
|
+
lines: [`${score}/100 Grade: ${grade} (${summary.healthy} healthy / ${summary.warning} warning / ${summary.error} error)`]
|
|
1490
550
|
});
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
}
|
|
1500
|
-
return { total: 0, passed: 0, failed: 0, checks };
|
|
1501
|
-
}
|
|
1502
|
-
function resolveCheckStatus(pkgName, ct, results) {
|
|
1503
|
-
const r = results[ct];
|
|
1504
|
-
if (!r) {
|
|
1505
|
-
return "skipped";
|
|
1506
|
-
}
|
|
1507
|
-
if (r.failed.includes(pkgName)) {
|
|
1508
|
-
return "failed";
|
|
1509
|
-
}
|
|
1510
|
-
if (r.passed.includes(pkgName)) {
|
|
1511
|
-
return "passed";
|
|
551
|
+
const catLines = Object.entries(by_category).map(([cat, data]) => {
|
|
552
|
+
const pct = data.total > 0 ? Math.round(data.healthy / data.total * 100) : 0;
|
|
553
|
+
return ` ${cat.padEnd(20)} ${data.grade} ${pct}% (${data.healthy}/${data.total})`;
|
|
554
|
+
});
|
|
555
|
+
sections.push({ header: "By Category", lines: catLines });
|
|
556
|
+
if (Object.keys(coverage).length > 0) {
|
|
557
|
+
const covLines = Object.entries(coverage).map(([k, v]) => ` ${k.padEnd(20)} ${v.pct}% (${v.pass}/${v.total})`);
|
|
558
|
+
sections.push({ header: "Coverage", lines: covLines });
|
|
1512
559
|
}
|
|
1513
|
-
return
|
|
560
|
+
return sections;
|
|
1514
561
|
}
|
|
1515
|
-
function
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
checks,
|
|
1529
|
-
errors
|
|
1530
|
-
};
|
|
562
|
+
function buildHistoryTable(history, limit = 20) {
|
|
563
|
+
const rows = [...history].reverse().slice(0, limit);
|
|
564
|
+
if (rows.length === 0) {
|
|
565
|
+
return [{ header: "History", lines: ["No history available."] }];
|
|
566
|
+
}
|
|
567
|
+
const lines = rows.map((snap) => {
|
|
568
|
+
const status = snap.raw.ok ? "pass" : "fail";
|
|
569
|
+
const tasks = snap.tasks.join(", ");
|
|
570
|
+
const commit = snap.git?.commit ?? "-------";
|
|
571
|
+
const date = new Date(snap.timestamp).toLocaleString();
|
|
572
|
+
return ` ${date} ${commit} ${status} [${tasks}]`;
|
|
573
|
+
});
|
|
574
|
+
return [{ header: `History (last ${rows.length})`, lines }];
|
|
1531
575
|
}
|
|
1532
|
-
function
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
if (hasFail) {
|
|
1536
|
-
summary.failed++;
|
|
1537
|
-
} else {
|
|
1538
|
-
summary.passed++;
|
|
576
|
+
function buildTrendsReport(analysis) {
|
|
577
|
+
if (analysis.tasks.length === 0) {
|
|
578
|
+
return [{ header: "Trends", lines: ["Not enough history for trend analysis."] }];
|
|
1539
579
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
summary.checks[ct].skipped++;
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
function groupResults(results, packages, categoryMap, config) {
|
|
1555
|
-
const checkTypes = Object.keys(results);
|
|
1556
|
-
const grouped = { categories: {} };
|
|
1557
|
-
for (const pkg of packages) {
|
|
1558
|
-
const categoryKeys = categoryMap.get(pkg.name) ?? ["uncategorized"];
|
|
1559
|
-
for (const categoryKey of categoryKeys) {
|
|
1560
|
-
const status = buildPackageStatus(pkg, results, categoryKey);
|
|
1561
|
-
if (!grouped.categories[categoryKey]) {
|
|
1562
|
-
const label2 = categoryKey === "uncategorized" ? "Uncategorized" : config?.categories?.[categoryKey]?.label ?? categoryKey;
|
|
1563
|
-
grouped.categories[categoryKey] = {
|
|
1564
|
-
label: label2,
|
|
1565
|
-
repos: {},
|
|
1566
|
-
summary: emptyGroupSummary(checkTypes)
|
|
1567
|
-
};
|
|
580
|
+
const sections = [];
|
|
581
|
+
for (const trend of analysis.tasks) {
|
|
582
|
+
const arrow = trend.direction === "regression" ? "\u2191" : trend.direction === "improvement" ? "\u2193" : "\u2192";
|
|
583
|
+
const lines = [
|
|
584
|
+
`${arrow} ${trend.direction} prev ${trend.previous} curr ${trend.current} delta ${trend.delta > 0 ? "+" : ""}${trend.delta} velocity ${trend.velocity}`
|
|
585
|
+
];
|
|
586
|
+
if (trend.changelog.length > 0) {
|
|
587
|
+
const last = trend.changelog[trend.changelog.length - 1];
|
|
588
|
+
if (last.newFailures.length > 0) {
|
|
589
|
+
lines.push(` last regression: ${last.newFailures.slice(0, 3).join(", ")}`);
|
|
1568
590
|
}
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
categoryGroup.repos[pkg.repo] = {
|
|
1572
|
-
packages: [],
|
|
1573
|
-
summary: emptyGroupSummary(checkTypes)
|
|
1574
|
-
};
|
|
591
|
+
if (last.fixed.length > 0) {
|
|
592
|
+
lines.push(` last fixed: ${last.fixed.slice(0, 3).join(", ")}`);
|
|
1575
593
|
}
|
|
1576
|
-
const repoGroup = categoryGroup.repos[pkg.repo];
|
|
1577
|
-
repoGroup.packages.push(status);
|
|
1578
|
-
addToSummary(repoGroup.summary, status);
|
|
1579
|
-
addToSummary(categoryGroup.summary, status);
|
|
1580
594
|
}
|
|
595
|
+
sections.push({ header: formatTaskName(trend.task), lines });
|
|
1581
596
|
}
|
|
1582
|
-
return
|
|
597
|
+
return sections;
|
|
1583
598
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
if (checkType === "lint") {
|
|
1588
|
-
const ruleMatch = errorText.match(/(\S+\/[\w-]+|no-[\w-]+)/);
|
|
1589
|
-
if (ruleMatch?.[1]) {
|
|
1590
|
-
return ruleMatch[1];
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
if (checkType === "typeCheck") {
|
|
1594
|
-
const tsMatch = errorText.match(/TS(\d{4,5})/);
|
|
1595
|
-
if (tsMatch) {
|
|
1596
|
-
return `TS${tsMatch[1]}`;
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
if (checkType === "test") {
|
|
1600
|
-
const failMatch = errorText.match(/FAIL\s+(\S+)/);
|
|
1601
|
-
if (failMatch) {
|
|
1602
|
-
return `FAIL: ${failMatch[1]}`;
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
if (checkType === "build") {
|
|
1606
|
-
if (errorText.includes("Cannot find module")) {
|
|
1607
|
-
return "Cannot find module";
|
|
1608
|
-
}
|
|
1609
|
-
if (errorText.includes("Module not found")) {
|
|
1610
|
-
return "Module not found";
|
|
1611
|
-
}
|
|
599
|
+
function buildRegressionsReport(detection) {
|
|
600
|
+
if (!detection.hasRegressions) {
|
|
601
|
+
return [{ header: "Regressions", lines: ["No regressions detected."] }];
|
|
1612
602
|
}
|
|
1613
|
-
const
|
|
1614
|
-
|
|
603
|
+
const sections = [];
|
|
604
|
+
for (const reg of detection.regressions) {
|
|
605
|
+
const lines = [
|
|
606
|
+
`delta +${reg.delta}`,
|
|
607
|
+
...reg.newFailures.slice(0, 5).map((p) => ` \u2717 ${p}`)
|
|
608
|
+
];
|
|
609
|
+
if (reg.newFailures.length > 5) {
|
|
610
|
+
lines.push(` ... ${reg.newFailures.length - 5} more`);
|
|
611
|
+
}
|
|
612
|
+
sections.push({ header: formatTaskName(reg.task), lines });
|
|
613
|
+
}
|
|
614
|
+
const prev = new Date(detection.comparedAt.previous).toLocaleString();
|
|
615
|
+
const curr = new Date(detection.comparedAt.current).toLocaleString();
|
|
616
|
+
sections.push({ header: "Compared", lines: [`${prev} \u2192 ${curr}`] });
|
|
617
|
+
return sections;
|
|
1615
618
|
}
|
|
1616
|
-
function
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
}
|
|
619
|
+
function buildBaselineReport(baseline) {
|
|
620
|
+
if (!baseline) {
|
|
621
|
+
return [{ header: "Baseline", lines: ["No baseline set. Run `qa baseline update` to set one."] }];
|
|
622
|
+
}
|
|
623
|
+
const { score, grade } = baseline.stats;
|
|
624
|
+
const totalIssues = Object.values(baseline.check.packages).reduce((sum, pkg) => sum + (pkg.issues?.length ?? 0), 0);
|
|
625
|
+
const commit = baseline.git?.commit ?? "unknown";
|
|
626
|
+
const date = new Date(baseline.timestamp).toLocaleString();
|
|
627
|
+
return [{
|
|
628
|
+
header: "Baseline",
|
|
629
|
+
lines: [`score ${score}/100 grade ${grade} issues ${totalIssues} commit ${commit} set ${date}`]
|
|
630
|
+
}];
|
|
631
|
+
}
|
|
632
|
+
function buildBaselineDiffReport(diff) {
|
|
633
|
+
const sections = [];
|
|
634
|
+
const scoreDir = diff.scoreDelta > 0 ? "+" : "";
|
|
635
|
+
sections.push({
|
|
636
|
+
header: "Score Delta",
|
|
637
|
+
lines: [`${scoreDir}${diff.scoreDelta} ${diff.gradeDelta} new ${diff.newIssueCount} fixed ${diff.fixedIssueCount}`]
|
|
638
|
+
});
|
|
639
|
+
if (diff.newIssues.length > 0) {
|
|
640
|
+
const lines = diff.newIssues.slice(0, 10).map((i) => ` [${i.severity}] ${i.pkg} ${i.check}: ${i.message}`);
|
|
641
|
+
if (diff.newIssues.length > 10) {
|
|
642
|
+
lines.push(` ... ${diff.newIssues.length - 10} more`);
|
|
1640
643
|
}
|
|
644
|
+
sections.push({ header: "New Issues", lines });
|
|
1641
645
|
}
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
if (
|
|
1645
|
-
|
|
1646
|
-
} else {
|
|
1647
|
-
groups.push(group);
|
|
646
|
+
if (diff.fixedIssues.length > 0) {
|
|
647
|
+
const lines = diff.fixedIssues.slice(0, 10).map((i) => ` [${i.severity}] ${i.pkg} ${i.check}: ${i.message}`);
|
|
648
|
+
if (diff.fixedIssues.length > 10) {
|
|
649
|
+
lines.push(` ... ${diff.fixedIssues.length - 10} more`);
|
|
1648
650
|
}
|
|
651
|
+
sections.push({ header: "Fixed Issues", lines });
|
|
1649
652
|
}
|
|
1650
|
-
|
|
1651
|
-
|
|
653
|
+
if (diff.persistingIssues.length > 0) {
|
|
654
|
+
sections.push({ header: "Persisting Issues", lines: [`${diff.persistingIssues.length} issues unchanged`] });
|
|
655
|
+
}
|
|
656
|
+
return sections;
|
|
1652
657
|
}
|
|
1653
658
|
|
|
1654
|
-
// src/
|
|
1655
|
-
function
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
659
|
+
// src/report/json-reporter.ts
|
|
660
|
+
function buildRunJsonReport(snap) {
|
|
661
|
+
return {
|
|
662
|
+
id: snap.id,
|
|
663
|
+
timestamp: snap.timestamp,
|
|
664
|
+
git: snap.git,
|
|
665
|
+
durationMs: snap.durationMs,
|
|
666
|
+
tasks: snap.tasks,
|
|
667
|
+
ok: snap.raw.ok,
|
|
668
|
+
elapsed: snap.raw.elapsed,
|
|
669
|
+
summary: snap.raw.summary,
|
|
670
|
+
results: snap.raw.results
|
|
671
|
+
};
|
|
1665
672
|
}
|
|
1666
|
-
function
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
673
|
+
function buildCheckJsonReport(snap) {
|
|
674
|
+
return {
|
|
675
|
+
id: snap.id,
|
|
676
|
+
timestamp: snap.timestamp,
|
|
677
|
+
git: snap.git,
|
|
678
|
+
durationMs: snap.durationMs,
|
|
679
|
+
ok: snap.raw.ok,
|
|
680
|
+
packages: snap.raw.packages
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
function buildStatsJsonReport(snap) {
|
|
684
|
+
return {
|
|
685
|
+
id: snap.id,
|
|
686
|
+
timestamp: snap.timestamp,
|
|
687
|
+
git: snap.git,
|
|
688
|
+
durationMs: snap.durationMs,
|
|
689
|
+
ok: snap.raw.ok,
|
|
690
|
+
score: snap.raw.score,
|
|
691
|
+
grade: snap.raw.grade,
|
|
692
|
+
summary: snap.raw.summary,
|
|
693
|
+
by_category: snap.raw.by_category,
|
|
694
|
+
issues_by_type: snap.raw.issues_by_type,
|
|
695
|
+
coverage: snap.raw.coverage
|
|
696
|
+
};
|
|
1688
697
|
}
|
|
1689
698
|
|
|
1690
|
-
export {
|
|
699
|
+
export { DevkitAdapter, SnapshotStore, analyzeTrends, buildBaselineDiffReport, buildBaselineReport, buildCheckJsonReport, buildCheckReport, buildHistoryTable, buildPackageTimeline, buildRegressionsReport, buildRunJsonReport, buildRunReport, buildStatsJsonReport, buildStatsReport, buildTrendsReport, captureGit, compareWithBaseline, detectRegressions, formatTaskName, resolveDevkitBin };
|
|
1691
700
|
//# sourceMappingURL=index.js.map
|
|
1692
701
|
//# sourceMappingURL=index.js.map
|