@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.js CHANGED
@@ -1,1692 +1,701 @@
1
- import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs';
2
- import { join, relative, dirname, resolve } from 'path';
3
- import { execSync, spawnSync } from 'child_process';
4
- import { createHash } from 'crypto';
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/runner/workspace.ts
8
- function getSubmoduleInfo(repoDir, repoName) {
9
- const gitDir = join(repoDir, ".git");
10
- if (!existsSync(gitDir)) {
11
- return null;
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
- return { nameMap, inDegree, dependents };
237
- }
238
- function computeBuildLayers(packages) {
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
- const { nameMap, inDegree, dependents } = buildDepGraph(packages);
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/runner/custom-check-runner.ts
273
- var ID_MAP = {
274
- build: "build",
275
- lint: "lint",
276
- typecheck: "typeCheck",
277
- "type-check": "typeCheck",
278
- test: "test",
279
- tests: "test"
280
- };
281
- function emptyResult() {
282
- return { passed: [], failed: [], skipped: [], errors: {}, details: {} };
283
- }
284
- function runCommand(command, args, cwd, timeoutMs, extraEnv) {
285
- try {
286
- const env = extraEnv ? { ...process.env, ...extraEnv } : void 0;
287
- const result = spawnSync(command, args, { cwd, timeout: timeoutMs, encoding: "utf-8", shell: false, env });
288
- const stdout = result.stdout ?? "";
289
- const stderr = result.stderr ?? "";
290
- const exitCode = result.status ?? 1;
291
- return { ok: exitCode === 0, stdout, stderr, exitCode };
292
- } catch (e) {
293
- return { ok: false, stdout: "", stderr: e instanceof Error ? e.message : String(e), exitCode: 1 };
294
- }
295
- }
296
- function parseTypedOutput(stdout) {
297
- try {
298
- const parsed = JSON.parse(stdout);
299
- if (typeof parsed === "object" && parsed !== null && "ok" in parsed) {
300
- return {
301
- ok: parsed.ok === true,
302
- items: Array.isArray(parsed.items) ? parsed.items : []
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
- } catch {
63
+ return result.stdout.trim();
306
64
  }
307
- return null;
308
- }
309
- function evaluate(check, stdout, stderr, exitCode) {
310
- if ((check.parser ?? "exitcode") === "json") {
311
- const typed = parseTypedOutput(stdout);
312
- if (typed !== null) {
313
- return typed.ok;
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
- const parsed = JSON.parse(stdout);
317
- return parsed.ok === true || parsed.success === true || parsed.status === "ok";
77
+ return JSON.parse(result.stdout);
318
78
  } catch {
319
- return false;
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
- function getDiffPackageDirs(rootDir, base, diffFilter) {
84
+ };
85
+
86
+ // src/devkit/git-capture.ts
87
+ async function captureGit(shell, cwd) {
341
88
  try {
342
- const filter = diffFilter ? `--diff-filter=${diffFilter}` : "";
343
- const args = ["diff", filter, `${base}...HEAD`, "--name-only"].filter(Boolean);
344
- const output = execSync(`git ${args.join(" ")}`, { cwd: rootDir, encoding: "utf-8" }).trim();
345
- if (!output) {
346
- return /* @__PURE__ */ new Set();
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 new Set(output.split("\n").map((f) => f.split("/")[0]).filter((s) => Boolean(s)));
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 loadCache(rootDir) {
465
- const cachePath = join(rootDir, PATHS.CACHE);
466
- if (!existsSync(cachePath)) {
467
- return {};
106
+ function readJson(filePath) {
107
+ if (!existsSync(filePath)) {
108
+ return [];
468
109
  }
469
110
  try {
470
- return JSON.parse(readFileSync(cachePath, "utf-8"));
111
+ return JSON.parse(readFileSync(filePath, "utf-8"));
471
112
  } catch {
472
- return {};
113
+ return [];
473
114
  }
474
115
  }
475
- function saveCache(rootDir, cache) {
476
- const cachePath = join(rootDir, PATHS.CACHE);
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(cachePath, JSON.stringify(cache, null, 2));
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 updateCacheEntry(pkgDir, pkgName, cache) {
509
- const hash = computePackageHash(pkgDir);
123
+ function buildMeta(durationMs, git, runContext) {
510
124
  return {
511
- ...cache,
512
- [pkgName]: { hash, timestamp: (/* @__PURE__ */ new Date()).toISOString() }
125
+ id: randomUUID(),
126
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
127
+ durationMs,
128
+ ...git ? { git } : {},
129
+ ...runContext ? { runContext } : {}
513
130
  };
514
131
  }
515
- function needsRebuild(pkgDir) {
516
- const srcDir = join(pkgDir, "src");
517
- const distDir = join(pkgDir, "dist");
518
- if (!existsSync(distDir)) {
519
- return true;
520
- }
521
- if (!existsSync(srcDir)) {
522
- return false;
523
- }
524
- const srcMtime = getLatestMtime(srcDir);
525
- const distMtime = getLatestMtime(distDir);
526
- return srcMtime > distMtime;
527
- }
528
- function getLatestMtime(dir) {
529
- let latest = 0;
530
- try {
531
- const entries = readdirSync(dir, { withFileTypes: true });
532
- for (const entry of entries) {
533
- const fullPath = join(dir, entry.name);
534
- if (entry.isDirectory()) {
535
- latest = Math.max(latest, getLatestMtime(fullPath));
536
- } else {
537
- latest = Math.max(latest, statSync(fullPath).mtimeMs);
538
- }
539
- }
540
- } catch {
541
- }
542
- return latest;
543
- }
544
- function runBuildCheck(options) {
545
- const { packages, noCache, onProgress } = options;
546
- const result = { passed: [], failed: [], skipped: [], errors: {} };
547
- const sorted = sortByBuildLayers(packages);
548
- for (const pkg of sorted) {
549
- if (!noCache && !needsRebuild(pkg.dir)) {
550
- result.skipped.push(pkg.name);
551
- onProgress?.(pkg.name, "skip");
552
- continue;
553
- }
554
- const startMs = Date.now();
555
- try {
556
- execSync("pnpm run build", {
557
- cwd: pkg.dir,
558
- encoding: "utf-8",
559
- timeout: 12e4,
560
- stdio: ["pipe", "pipe", "pipe"]
561
- });
562
- const durationMs = Date.now() - startMs;
563
- result.passed.push(pkg.name);
564
- onProgress?.(pkg.name, "pass", durationMs);
565
- } catch (err) {
566
- const durationMs = Date.now() - startMs;
567
- result.failed.push(pkg.name);
568
- const spawnErr = err;
569
- const rawErr = (spawnErr.stderr || spawnErr.stdout || spawnErr.message || "").trim();
570
- result.errors[pkg.name] = rawErr.slice(0, 2e3) || `Build failed (exit code ${spawnErr.status ?? 1})`;
571
- onProgress?.(pkg.name, "fail", durationMs);
572
- }
573
- }
574
- return result;
575
- }
576
- function runLintCheck(options) {
577
- const { packages, onProgress } = options;
578
- const result = { passed: [], failed: [], skipped: [], errors: {} };
579
- for (const pkg of packages) {
580
- const srcDir = join(pkg.dir, "src");
581
- if (!existsSync(srcDir)) {
582
- result.skipped.push(pkg.name);
583
- onProgress?.(pkg.name, "skip");
584
- continue;
585
- }
586
- const startMs = Date.now();
587
- try {
588
- execSync("pnpm exec eslint .", {
589
- cwd: pkg.dir,
590
- encoding: "utf-8",
591
- timeout: 6e4,
592
- stdio: ["pipe", "pipe", "pipe"]
593
- });
594
- result.passed.push(pkg.name);
595
- onProgress?.(pkg.name, "pass", Date.now() - startMs);
596
- } catch (err) {
597
- result.failed.push(pkg.name);
598
- const spawnErr = err;
599
- const rawErr = (spawnErr.stdout || spawnErr.stderr || spawnErr.message || "").trim();
600
- result.errors[pkg.name] = rawErr.slice(0, 2e3) || `Lint failed (exit code ${spawnErr.status ?? 1})`;
601
- onProgress?.(pkg.name, "fail", Date.now() - startMs);
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
- return result;
605
- }
606
- function runTypeCheck(options) {
607
- const { packages, onProgress } = options;
608
- const result = { passed: [], failed: [], skipped: [], errors: {} };
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
- pkgJson = JSON.parse(
643
- readFileSync(join(pkg.dir, "package.json"), "utf-8")
644
- );
231
+ return JSON.parse(readFileSync(p, "utf-8"));
645
232
  } catch {
646
- result.skipped.push(pkg.name);
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
- const data = {
682
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
683
- results,
684
- packages: packages.map((p) => ({
685
- name: p.name,
686
- dir: p.dir,
687
- relativePath: p.relativePath,
688
- repo: p.repo,
689
- submodule: p.submodule
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 runBuiltinChecks(options, packages, skipSet, results) {
714
- const { rootDir, noCache } = options;
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
- async function runQA(options) {
742
- const { rootDir, noCache } = options;
743
- const skipSet = new Set(
744
- (options.skipChecks ?? []).map((s) => SKIP_ALIASES[s.toLowerCase()] ?? s.toLowerCase())
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
- saveLastRun(rootDir, results, packages, Object.keys(submodules).length > 0 ? submodules : void 0);
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 createHistoryEntry(results, rootDir, packages, runContext) {
904
- let commit = "unknown";
905
- let branch = "unknown";
906
- let message = "";
907
- try {
908
- commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf-8" }).trim();
909
- branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: rootDir, encoding: "utf-8" }).trim();
910
- message = execSync("git log -1 --format=%s", { cwd: rootDir, encoding: "utf-8" }).trim();
911
- } catch {
912
- }
913
- const hasFailures = Object.values(results).some((r) => r.failed.length > 0);
914
- const summary = {};
915
- const failedPackages = {};
916
- for (const ct of Object.keys(results)) {
917
- const r = results[ct];
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
- failedPackages[ct] = [...r.failed];
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 collectCheckTypes(entries) {
963
- const s = /* @__PURE__ */ new Set();
964
- for (const e of entries) {
965
- for (const k of Object.keys(e.summary)) {
966
- s.add(k);
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 [...s];
299
+ return changelog;
970
300
  }
971
301
  function analyzeTrends(history, window = TRENDS_WINDOW) {
972
302
  if (history.length < 2) {
973
- return [];
974
- }
975
- const windowEntries = history.slice(-window);
976
- const first = windowEntries[0];
977
- const last = windowEntries[windowEntries.length - 1];
978
- const results = [];
979
- for (const ct of collectCheckTypes([first, last])) {
980
- const previous = first.summary[ct]?.failed ?? 0;
981
- const current = last.summary[ct]?.failed ?? 0;
982
- const delta = current - previous;
983
- let trend;
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
- let trend;
1040
- if (delta > 0) {
1041
- trend = "regression";
1042
- } else if (delta < 0) {
1043
- trend = "improvement";
1044
- } else {
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/history/regression-detector.ts
323
+ // src/analysis/regression-detector.ts
1065
324
  function detectRegressions(history) {
1066
325
  if (history.length < 2) {
1067
- return { hasRegressions: false, regressions: [] };
326
+ return {
327
+ hasRegressions: false,
328
+ regressions: [],
329
+ comparedAt: { previous: "", current: "" }
330
+ };
1068
331
  }
1069
- const previous = history[history.length - 2];
1070
- const current = history[history.length - 1];
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 checkTypes = [.../* @__PURE__ */ new Set([...Object.keys(previous.failedPackages), ...Object.keys(current.failedPackages)])];
1073
- for (const ct of checkTypes) {
1074
- const prevFailed = new Set(previous.failedPackages[ct] ?? []);
1075
- const currFailed = current.failedPackages[ct] ?? [];
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
- checkType: ct,
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/history/package-timeline.ts
1093
- function buildEntries(history, packageName) {
1094
- let repo = "unknown";
1095
- const entries = [];
1096
- for (let i = history.length - 1; i >= 0; i--) {
1097
- const h = history[i];
1098
- const checks = {};
1099
- let found = false;
1100
- for (const ct of Object.keys(h.summary)) {
1101
- const failedList = h.failedPackages[ct] ?? [];
1102
- const summaryEntry = h.summary[ct];
1103
- if (failedList.includes(packageName)) {
1104
- checks[ct] = "failed";
1105
- found = true;
1106
- } else if (summaryEntry && (summaryEntry.passed > 0 || summaryEntry.failed > 0)) {
1107
- checks[ct] = "passed";
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
- if (!found) {
1114
- continue;
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
- let submoduleCommit;
1117
- if (h.submodules) {
1118
- for (const [repoName, info] of Object.entries(h.submodules)) {
1119
- if (repoName === repo || repo === "unknown") {
1120
- submoduleCommit = info.commit;
1121
- if (repo === "unknown") {
1122
- repo = repoName;
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
- return { entries, repo };
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
- function computeFlakyScore(entries) {
1137
- const allCheckTypes = /* @__PURE__ */ new Set();
1138
- for (const entry of entries) {
1139
- for (const k of Object.keys(entry.checks)) {
1140
- allCheckTypes.add(k);
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 flakyChecks = [];
430
+ const taskNames = [...new Set(entries.map((e) => e.task))];
1144
431
  let totalFlips = 0;
1145
432
  let totalTransitions = 0;
1146
- for (const ct of allCheckTypes) {
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 transitions = 0;
1149
- for (let i = 1; i < entries.length; i++) {
1150
- const prev = entries[i - 1].checks[ct];
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 (transitions > 0 && flips / transitions > 0.3) {
1161
- flakyChecks.push(ct);
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
- return {
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
- if (!latest) {
1174
- return { status: "passing", count: 0 };
1175
- }
1176
- const streakStatus = Object.values(latest.checks).some((v) => v === "failed") ? "failing" : "passing";
1177
- let count = 1;
1178
- for (let i = 1; i < entries.length; i++) {
1179
- const eFail = Object.values(entries[i].checks).some((v) => v === "failed");
1180
- if ((eFail ? "failing" : "passing") !== streakStatus) {
1181
- break;
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 (let i = entries.length - 1; i >= 0; i--) {
1192
- if (Object.values(entries[i].checks).some((v) => v === "failed")) {
1193
- firstFailure = entries[i].timestamp;
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
- repo,
1199
- entries,
1200
- flakyScore: Math.round(flakyScore * 100) / 100,
1201
- flakyChecks,
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/json-reporter.ts
1208
- function buildJsonReport(results, diff, checks) {
1209
- const severityMap = {};
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 buildBaselineDiffLines(diff) {
1274
- const lines = [];
1275
- for (const ct of Object.keys(diff)) {
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 summaryLines = [];
1292
- let totalPassed = 0;
1293
- let totalFailed = 0;
1294
- let totalSkipped = 0;
1295
- for (const ct of Object.keys(results)) {
1296
- const r = results[ct];
1297
- const total = r.passed.length + r.failed.length + r.skipped.length;
1298
- const pct = total > 0 ? Math.round(r.passed.length / total * 100) : 100;
1299
- const status = r.failed.length === 0 ? "PASS" : "FAIL";
1300
- summaryLines.push(`${status} ${icon(ct)} ${label(ct).padEnd(12)} ${r.passed.length}/${total} passed (${pct}%)`);
1301
- if (r.failed.length > 0) {
1302
- for (const pkg of r.failed.slice(0, 5)) {
1303
- summaryLines.push(` - ${pkg}`);
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: "Totals",
1322
- lines: [`Total: ${totalPassed} passed, ${totalFailed} failed, ${totalSkipped} skipped`]
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 buildHistoryTable(history, limit = 20) {
1327
- const entries = history.slice(-limit);
1328
- const lines = [];
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
- for (const r of result.regressions) {
1372
- lines.push(`${r.checkType}: +${r.newFailures.length} new failures`);
1373
- for (const pkg of r.newFailures) {
1374
- lines.push(` - ${pkg}`);
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
- lines.push("");
1378
- lines.push("REGRESSIONS DETECTED!");
1379
- return [{ header: "Regression Detection", lines }];
1380
- }
1381
- function buildBaselineReport(baseline) {
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
- return [{ header: "Baseline Status", lines }];
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
- return "";
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 renderCategoryLines(catKey, grouped) {
1440
- const cat = grouped.categories[catKey];
1441
- const lines = [`PASS ${cat.summary.passed} | FAIL ${cat.summary.failed}`, ""];
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
- return lines;
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: "Totals",
1489
- lines: [`Total: ${totalPassed} passed, ${totalFailed} failed (${categoryKeys.length} categories)`]
548
+ header: "Score",
549
+ lines: [`${score}/100 Grade: ${grade} (${summary.healthy} healthy / ${summary.warning} warning / ${summary.error} error)`]
1490
550
  });
1491
- return sections;
1492
- }
1493
-
1494
- // src/report/grouped-reporter.ts
1495
- function emptyGroupSummary(checkTypes) {
1496
- const checks = {};
1497
- for (const ct of checkTypes) {
1498
- checks[ct] = { passed: 0, failed: 0, skipped: 0 };
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 "skipped";
560
+ return sections;
1514
561
  }
1515
- function buildPackageStatus(pkg, results, category) {
1516
- const checks = {};
1517
- const errors = {};
1518
- for (const ct of Object.keys(results)) {
1519
- checks[ct] = resolveCheckStatus(pkg.name, ct, results);
1520
- if (checks[ct] === "failed" && results[ct]?.errors[pkg.name]) {
1521
- errors[ct] = results[ct].errors[pkg.name];
1522
- }
1523
- }
1524
- return {
1525
- name: pkg.name,
1526
- repo: pkg.repo,
1527
- category,
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 addToSummary(summary, status) {
1533
- summary.total++;
1534
- const hasFail = Object.values(status.checks).some((v) => v === "failed");
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
- for (const ct of Object.keys(status.checks)) {
1541
- const s = status.checks[ct];
1542
- if (!summary.checks[ct]) {
1543
- summary.checks[ct] = { passed: 0, failed: 0, skipped: 0 };
1544
- }
1545
- if (s === "passed") {
1546
- summary.checks[ct].passed++;
1547
- } else if (s === "failed") {
1548
- summary.checks[ct].failed++;
1549
- } else {
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
- const categoryGroup = grouped.categories[categoryKey];
1570
- if (!categoryGroup.repos[pkg.repo]) {
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 grouped;
597
+ return sections;
1583
598
  }
1584
-
1585
- // src/report/error-grouping.ts
1586
- function extractPattern(errorText, checkType) {
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 firstLine = errorText.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
1614
- return firstLine.slice(0, 100) || "Unknown error";
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 groupErrors(results) {
1617
- const groupMap = /* @__PURE__ */ new Map();
1618
- let ungrouped = 0;
1619
- for (const ct of Object.keys(results)) {
1620
- const check = results[ct];
1621
- if (!check.errors) {
1622
- continue;
1623
- }
1624
- for (const [pkgName, errorText] of Object.entries(check.errors)) {
1625
- const pattern = extractPattern(errorText, ct);
1626
- const key = `${ct}::${pattern}`;
1627
- const existing = groupMap.get(key);
1628
- if (existing) {
1629
- existing.count++;
1630
- existing.packages.push(pkgName);
1631
- } else {
1632
- groupMap.set(key, {
1633
- pattern,
1634
- count: 1,
1635
- packages: [pkgName],
1636
- checkType: ct,
1637
- example: errorText.slice(0, 200)
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
- const groups = [];
1643
- for (const group of groupMap.values()) {
1644
- if (group.count === 1) {
1645
- ungrouped++;
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
- groups.sort((a, b) => b.count - a.count);
1651
- return { groups, ungrouped };
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/categories/category-resolver.ts
1655
- function matchesPattern2(packageName, repo, pattern) {
1656
- if (pattern.includes("/") && pattern.endsWith("/*")) {
1657
- const repoPrefix = pattern.slice(0, -2);
1658
- return repo === repoPrefix;
1659
- }
1660
- if (!pattern.includes("*")) {
1661
- return packageName === pattern;
1662
- }
1663
- const prefix = pattern.slice(0, pattern.indexOf("*"));
1664
- return packageName.startsWith(prefix);
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 resolveCategories(packages, config) {
1667
- const map = /* @__PURE__ */ new Map();
1668
- if (!config?.categories) {
1669
- for (const pkg of packages) {
1670
- map.set(pkg.name, ["uncategorized"]);
1671
- }
1672
- return map;
1673
- }
1674
- const categoryEntries = Object.entries(config.categories);
1675
- for (const pkg of packages) {
1676
- const matched = [];
1677
- for (const [categoryKey, categoryConfig] of categoryEntries) {
1678
- for (const pattern of categoryConfig.packages) {
1679
- if (matchesPattern2(pkg.name, pkg.repo, pattern)) {
1680
- matched.push(categoryKey);
1681
- break;
1682
- }
1683
- }
1684
- }
1685
- map.set(pkg.name, matched.length > 0 ? matched : ["uncategorized"]);
1686
- }
1687
- return map;
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 { analyzeEnrichedTrends, analyzeTrends, appendEntry, appendTrendEntry, buildBaselineReport, buildDetailedJsonReport, buildDetailedRunReport, buildHistoryTable, buildJsonReport, buildRegressionsReport, buildRunReport, buildTrendsReport, captureBaseline, collectSubmoduleInfo, compareWithBaseline, createBaselineFromResults, createHistoryEntry, detectRegressions, getPackageTimeline, getSubmoduleInfo, getWorkspacePackages, groupErrors, groupResults, loadBaseline, loadHistory, loadLastRun, loadTrendHistory, resolveCategories, runCustomChecks, runLintCheck, runQA, runTestCheck, runTypeCheck, saveBaseline, saveHistory, saveLastRun };
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