qualink 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,7 +110,7 @@ qualink collect <collector> --input <path> --sink elastic [flags]
110
110
  ```bash
111
111
  qualink collect eslint --input eslint-report.json --sink elastic --repo frontend-mono --category frontend --tags frontend,web
112
112
  qualink collect sarif --input analyzers.sarif --sink elastic --repo backend-api --category backend --tags backend,api
113
- qualink collect coverage-dotnet --input coverage.cobertura.xml --sink elastic --repo backend-api
113
+ qualink collect coverage-cobertura --input coverage.cobertura.xml --sink elastic --repo backend-api
114
114
  ```
115
115
 
116
116
  ### Multi-collect
@@ -160,7 +160,7 @@ Collectors:
160
160
  - `lighthouse` (Lighthouse JSON)
161
161
  - `coverage-js` (Istanbul/Vitest JSON)
162
162
  - `sarif` (Roslyn or generic SARIF JSON)
163
- - `coverage-dotnet` (Cobertura/OpenCover XML)
163
+ - `coverage-cobertura` (Cobertura/OpenCover XML)
164
164
  - `junit` (JUnit XML)
165
165
 
166
166
  ESLint file-level options (optional):
@@ -1,4 +1,6 @@
1
+ import { stat } from "node:fs/promises";
1
2
  import { defineCommand } from "citty";
3
+ import { formatBytes } from "../utils/format.js";
2
4
  import { CliError } from "./cli-error.js";
3
5
  import { commonArgs, isDryRun } from "./common-args.js";
4
6
  import { parseCommonMetadata } from "./parse-metadata.js";
@@ -16,6 +18,11 @@ export function createCollectorCommand(config) {
16
18
  async run({ args }) {
17
19
  try {
18
20
  const parsedArgs = args;
21
+ const inputPath = typeof parsedArgs.input === "string" ? parsedArgs.input : undefined;
22
+ if (inputPath) {
23
+ const fileStat = await stat(inputPath);
24
+ process.stderr.write(` read: ${inputPath} (${formatBytes(fileStat.size)})\n`);
25
+ }
19
26
  const metadata = parseCommonMetadata(parsedArgs);
20
27
  const { metricType, documents } = await config.collect(parsedArgs, metadata);
21
28
  await sendToSink(metricType, parsedArgs, documents);
@@ -1,4 +1,4 @@
1
- export declare const coverageDotnetCommand: import("citty").CommandDef<{
1
+ export declare const coverageCoberturaCommand: import("citty").CommandDef<{
2
2
  readonly input: {
3
3
  readonly type: "string";
4
4
  readonly required: true;
@@ -0,0 +1,12 @@
1
+ import { collectCoverageCobertura } from "../../collectors/coverage-cobertura.js";
2
+ import { createCollectorCommand } from "../command-factory.js";
3
+ import { loadTextInput } from "../load-input.js";
4
+ export const coverageCoberturaCommand = createCollectorCommand({
5
+ name: "coverage-cobertura",
6
+ description: "Collect Cobertura XML coverage metrics and relay them",
7
+ async collect(args, metadata) {
8
+ const input = await loadTextInput(args);
9
+ const documents = collectCoverageCobertura(input, metadata);
10
+ return { metricType: "coverage-cobertura", documents };
11
+ },
12
+ });
@@ -1,5 +1,5 @@
1
1
  export { biomeCommand } from "./biome.js";
2
- export { coverageDotnetCommand } from "./coverage-dotnet.js";
2
+ export { coverageCoberturaCommand } from "./coverage-cobertura.js";
3
3
  export { coverageJsCommand } from "./coverage-js.js";
4
4
  export { eslintCommand } from "./eslint.js";
5
5
  export { junitCommand } from "./junit.js";
@@ -1,5 +1,5 @@
1
1
  export { biomeCommand } from "./biome.js";
2
- export { coverageDotnetCommand } from "./coverage-dotnet.js";
2
+ export { coverageCoberturaCommand } from "./coverage-cobertura.js";
3
3
  export { coverageJsCommand } from "./coverage-js.js";
4
4
  export { eslintCommand } from "./eslint.js";
5
5
  export { junitCommand } from "./junit.js";
package/dist/cli/index.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { relative } from "node:path";
1
2
  import { defineCommand, runMain } from "citty";
2
3
  import { CliError } from "./cli-error.js";
3
- import { biomeCommand, coverageDotnetCommand, coverageJsCommand, eslintCommand, junitCommand, lighthouseCommand, metaCommand, pipelineCommand, sarifCommand, } from "./commands/index.js";
4
+ import { biomeCommand, coverageCoberturaCommand, coverageJsCommand, eslintCommand, junitCommand, lighthouseCommand, metaCommand, pipelineCommand, sarifCommand, } from "./commands/index.js";
4
5
  import { commonArgs, isDryRun } from "./common-args.js";
5
6
  import { parseConfig, resolveConfig } from "./multi-collect/config.js";
6
7
  import { discoverFiles } from "./multi-collect/discover.js";
@@ -32,7 +33,7 @@ const collectCommand = defineCommand({
32
33
  lighthouse: lighthouseCommand,
33
34
  "coverage-js": coverageJsCommand,
34
35
  sarif: sarifCommand,
35
- "coverage-dotnet": coverageDotnetCommand,
36
+ "coverage-cobertura": coverageCoberturaCommand,
36
37
  junit: junitCommand,
37
38
  },
38
39
  async run({ args }) {
@@ -54,6 +55,11 @@ const collectCommand = defineCommand({
54
55
  async function runDirMode(dir, args) {
55
56
  const metadata = parseCommonMetadata(args);
56
57
  const discovered = await discoverFiles(dir);
58
+ for (const [collectorKey, files] of discovered) {
59
+ for (const filePath of files) {
60
+ process.stderr.write(` scan: ${relative(dir, filePath)} → ${collectorKey}\n`);
61
+ }
62
+ }
57
63
  const accumulated = new Map();
58
64
  const counts = new Map();
59
65
  for (const [collectorKey, files] of discovered) {
@@ -78,6 +84,11 @@ async function runConfigMode(configValue, args) {
78
84
  const metadata = parseCommonMetadata(args);
79
85
  const entries = await parseConfig(configValue);
80
86
  const resolved = await resolveConfig(entries, ".");
87
+ for (const entry of resolved) {
88
+ for (const filePath of entry.files) {
89
+ process.stderr.write(` scan: ${filePath} → ${entry.type}\n`);
90
+ }
91
+ }
81
92
  const accumulated = new Map();
82
93
  const counts = new Map();
83
94
  for (const entry of resolved) {
@@ -1,5 +1,5 @@
1
1
  import type { MetricType } from "../../types.js";
2
- export type CollectorKey = Extract<MetricType, "eslint" | "biome" | "coverage-js" | "coverage-dotnet" | "sarif" | "lighthouse" | "junit">;
2
+ export type CollectorKey = Extract<MetricType, "eslint" | "biome" | "coverage-js" | "coverage-cobertura" | "sarif" | "lighthouse" | "junit">;
3
3
  export declare const COLLECTOR_KEYS: readonly CollectorKey[];
4
4
  export interface FilePattern {
5
5
  /** Match against basename only */
@@ -2,7 +2,7 @@ export const COLLECTOR_KEYS = [
2
2
  "eslint",
3
3
  "biome",
4
4
  "coverage-js",
5
- "coverage-dotnet",
5
+ "coverage-cobertura",
6
6
  "sarif",
7
7
  "lighthouse",
8
8
  "junit",
@@ -11,7 +11,7 @@ export const COLLECTOR_PATTERNS = {
11
11
  eslint: [{ basename: "eslint-report.json" }],
12
12
  biome: [{ basename: "biome-report.json" }],
13
13
  "coverage-js": [{ basename: "coverage-summary.json" }],
14
- "coverage-dotnet": [
14
+ "coverage-cobertura": [
15
15
  { basename: "coverage.cobertura.xml" },
16
16
  { basename: "cobertura-coverage.xml" },
17
17
  ],
@@ -116,7 +116,7 @@ export function resolveFileMetadata(filePath, collectorKey) {
116
116
  const stopAt = gitRoot ?? fileDir;
117
117
  const overrides = {};
118
118
  // Detect project name
119
- if (collectorKey === "coverage-dotnet" || collectorKey === "sarif") {
119
+ if (collectorKey === "coverage-cobertura" || collectorKey === "sarif") {
120
120
  overrides.projectName = findNearestProjectName(fileDir, stopAt) ?? null;
121
121
  }
122
122
  else {
@@ -1,5 +1,5 @@
1
1
  import { collectBiome } from "../../collectors/biome.js";
2
- import { collectCoverageDotnet } from "../../collectors/coverage-dotnet.js";
2
+ import { collectCoverageCobertura } from "../../collectors/coverage-cobertura.js";
3
3
  import { collectCoverageJs } from "../../collectors/coverage-js.js";
4
4
  import { collectEslint } from "../../collectors/eslint.js";
5
5
  import { collectJunit } from "../../collectors/junit.js";
@@ -53,10 +53,10 @@ export async function runCollector(key, filePath, metadata, urlOverride) {
53
53
  const documents = collectCoverageJs(input, metadata);
54
54
  return { metricType: "coverage-js", documents };
55
55
  }
56
- case "coverage-dotnet": {
56
+ case "coverage-cobertura": {
57
57
  const input = await readTextFile(filePath);
58
- const documents = collectCoverageDotnet(input, metadata);
59
- return { metricType: "coverage-dotnet", documents };
58
+ const documents = collectCoverageCobertura(input, metadata);
59
+ return { metricType: "coverage-cobertura", documents };
60
60
  }
61
61
  case "sarif": {
62
62
  const input = await readJsonFile(filePath);
@@ -1,3 +1,4 @@
1
+ import { INDEX_BY_TYPE } from "../sinks/elastic.js";
1
2
  import { createSink } from "../sinks/index.js";
2
3
  import { CliError } from "./cli-error.js";
3
4
  import { argValue, envOrArg, isDryRun } from "./common-args.js";
@@ -65,8 +66,22 @@ export async function sendToSink(metricType, args, documents) {
65
66
  sinkConfig = sinkConfigBase;
66
67
  }
67
68
  const sink = createSink(sinkConfig);
68
- await sink.send({
69
+ const { durationMs } = await sink.send({
69
70
  metricType,
70
71
  documents,
71
72
  });
73
+ const count = documents.length;
74
+ const ms = Math.round(durationMs);
75
+ if (sinkKind === "elastic") {
76
+ const index = INDEX_BY_TYPE[metricType];
77
+ const url = sinkConfig.elasticUrl ?? "";
78
+ process.stderr.write(` sent: ${count} document(s) → elastic ${index} (${url}) ${ms}ms\n`);
79
+ }
80
+ else if (sinkKind === "loki") {
81
+ const url = sinkConfig.lokiUrl ?? "";
82
+ process.stderr.write(` sent: ${count} document(s) → loki (${url}) ${ms}ms\n`);
83
+ }
84
+ else {
85
+ process.stderr.write(` sent: ${count} document(s) → stdout\n`);
86
+ }
72
87
  }
@@ -0,0 +1,2 @@
1
+ import type { CoberturaCoverageMetricDocument, CommonMetadata } from "../types.js";
2
+ export declare function collectCoverageCobertura(xmlInput: string, metadata: CommonMetadata): CoberturaCoverageMetricDocument[];
@@ -39,7 +39,7 @@ function parseOpenCover(root) {
39
39
  branchesCovered: readNumber(summary, "@_visitedBranchPoints"),
40
40
  };
41
41
  }
42
- export function collectCoverageDotnet(xmlInput, metadata) {
42
+ export function collectCoverageCobertura(xmlInput, metadata) {
43
43
  const parser = new XMLParser({
44
44
  ignoreAttributes: false,
45
45
  attributeNamePrefix: "@_",
@@ -76,8 +76,8 @@ export function collectCoverageDotnet(xmlInput, metadata) {
76
76
  const functionsCovered = 0;
77
77
  const doc = {
78
78
  ...baseDocument({
79
- metricType: "coverage-dotnet",
80
- tool: "dotnet-test",
79
+ metricType: "coverage-cobertura",
80
+ tool: "cobertura",
81
81
  languages: ["csharp"],
82
82
  metadata,
83
83
  }),
@@ -1,5 +1,5 @@
1
1
  export { collectBiome } from "./biome.js";
2
- export { collectCoverageDotnet } from "./coverage-dotnet.js";
2
+ export { collectCoverageCobertura } from "./coverage-cobertura.js";
3
3
  export { collectCoverageJs } from "./coverage-js.js";
4
4
  export { collectEslint } from "./eslint.js";
5
5
  export { collectLighthouse } from "./lighthouse.js";
@@ -1,5 +1,5 @@
1
1
  export { collectBiome } from "./biome.js";
2
- export { collectCoverageDotnet } from "./coverage-dotnet.js";
2
+ export { collectCoverageCobertura } from "./coverage-cobertura.js";
3
3
  export { collectCoverageJs } from "./coverage-js.js";
4
4
  export { collectEslint } from "./eslint.js";
5
5
  export { collectLighthouse } from "./lighthouse.js";
@@ -1,11 +1,12 @@
1
- import type { NormalizedDocument } from "../types.js";
2
- import type { SendInput, Sink } from "./types.js";
1
+ import type { MetricType, NormalizedDocument } from "../types.js";
2
+ import type { SendInput, SendResult, Sink } from "./types.js";
3
3
  interface ElasticSinkOptions {
4
4
  url: string;
5
5
  apiKey: string;
6
6
  retryMax: number;
7
7
  retryBackoffMs: number;
8
8
  }
9
+ export declare const INDEX_BY_TYPE: Record<MetricType, string>;
9
10
  export declare function buildBulkBody(indexName: string, documents: NormalizedDocument[]): string;
10
11
  export declare class ElasticSink implements Sink {
11
12
  private readonly url;
@@ -13,6 +14,6 @@ export declare class ElasticSink implements Sink {
13
14
  private readonly retryMax;
14
15
  private readonly retryBackoffMs;
15
16
  constructor(options: ElasticSinkOptions);
16
- send(input: SendInput): Promise<void>;
17
+ send(input: SendInput): Promise<SendResult>;
17
18
  }
18
19
  export {};
@@ -1,11 +1,11 @@
1
1
  import { isRecord } from "../utils/guards.js";
2
- const INDEX_BY_TYPE = {
2
+ export const INDEX_BY_TYPE = {
3
3
  biome: "codequality-biome",
4
4
  eslint: "codequality-eslint",
5
5
  lighthouse: "codequality-lighthouse",
6
6
  "coverage-js": "codequality-coverage-js",
7
7
  sarif: "codequality-sarif",
8
- "coverage-dotnet": "codequality-coverage-dotnet",
8
+ "coverage-cobertura": "codequality-coverage-cobertura",
9
9
  junit: "codequality-junit",
10
10
  meta: "codequality-meta",
11
11
  pipeline: "codequality-pipeline",
@@ -63,8 +63,9 @@ export class ElasticSink {
63
63
  }
64
64
  async send(input) {
65
65
  if (input.documents.length === 0) {
66
- return;
66
+ return { durationMs: 0 };
67
67
  }
68
+ const start = performance.now();
68
69
  const indexName = INDEX_BY_TYPE[input.metricType];
69
70
  let documents = input.documents;
70
71
  let attempt = 0;
@@ -99,7 +100,7 @@ export class ElasticSink {
99
100
  if (nonRetryableErrors.length > 0) {
100
101
  throw new Error(`Elastic bulk request completed with ${nonRetryableErrors.length} non-retryable item error(s)`);
101
102
  }
102
- return;
103
+ return { durationMs: performance.now() - start };
103
104
  }
104
105
  if (attempt > this.retryMax) {
105
106
  const deadLetterBody = buildBulkBody(indexName, retryable);
@@ -110,7 +111,7 @@ export class ElasticSink {
110
111
  await sleep(this.retryBackoffMs * attempt);
111
112
  continue;
112
113
  }
113
- return;
114
+ return { durationMs: performance.now() - start };
114
115
  }
115
116
  }
116
117
  }
@@ -1,5 +1,5 @@
1
1
  import type { NormalizedDocument } from "../types.js";
2
- import type { SendInput, Sink } from "./types.js";
2
+ import type { SendInput, SendResult, Sink } from "./types.js";
3
3
  export interface LokiSinkOptions {
4
4
  url: string;
5
5
  username?: string | undefined;
@@ -25,6 +25,6 @@ export declare class LokiSink implements Sink {
25
25
  private readonly retryMax;
26
26
  private readonly retryBackoffMs;
27
27
  constructor(options: LokiSinkOptions);
28
- send(input: SendInput): Promise<void>;
28
+ send(input: SendInput): Promise<SendResult>;
29
29
  }
30
30
  export {};
@@ -48,8 +48,9 @@ export class LokiSink {
48
48
  }
49
49
  async send(input) {
50
50
  if (input.documents.length === 0) {
51
- return;
51
+ return { durationMs: 0 };
52
52
  }
53
+ const start = performance.now();
53
54
  const payload = buildLokiPayload(input.documents);
54
55
  const body = JSON.stringify(payload);
55
56
  const headers = {
@@ -71,7 +72,7 @@ export class LokiSink {
71
72
  body,
72
73
  });
73
74
  if (response.status === 204 || response.ok) {
74
- return;
75
+ return { durationMs: performance.now() - start };
75
76
  }
76
77
  const responseText = await response.text();
77
78
  if (!isRetryableStatus(response.status) || attempt > this.retryMax) {
@@ -1,4 +1,4 @@
1
- import type { SendInput, Sink } from "./types.js";
1
+ import type { SendInput, SendResult, Sink } from "./types.js";
2
2
  export declare class StdoutSink implements Sink {
3
- send(input: SendInput): Promise<void>;
3
+ send(input: SendInput): Promise<SendResult>;
4
4
  }
@@ -6,5 +6,6 @@ export class StdoutSink {
6
6
  documents: input.documents,
7
7
  };
8
8
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
9
+ return { durationMs: 0 };
9
10
  }
10
11
  }
@@ -3,6 +3,9 @@ export interface SendInput {
3
3
  metricType: MetricType;
4
4
  documents: NormalizedDocument[];
5
5
  }
6
+ export interface SendResult {
7
+ durationMs: number;
8
+ }
6
9
  export interface Sink {
7
- send(input: SendInput): Promise<void>;
10
+ send(input: SendInput): Promise<SendResult>;
8
11
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type Language = "js" | "ts" | "csharp" | (string & {});
2
2
  export type Environment = "dev" | "test" | "prod" | "ci";
3
- export type MetricType = "biome" | "eslint" | "lighthouse" | "coverage-js" | "sarif" | "coverage-dotnet" | "junit" | "meta" | "pipeline";
3
+ export type MetricType = "biome" | "eslint" | "lighthouse" | "coverage-js" | "sarif" | "coverage-cobertura" | "junit" | "meta" | "pipeline";
4
4
  export interface BaseMetricDocument {
5
5
  "@timestamp": string;
6
6
  metric_type: MetricType;
@@ -102,8 +102,8 @@ export interface BiomeFileIssue {
102
102
  fixable_errors: number;
103
103
  fixable_warnings: number;
104
104
  }
105
- export interface DotnetCoverageMetricDocument extends CoverageMetricDocument {
106
- metric_type: "coverage-dotnet";
105
+ export interface CoberturaCoverageMetricDocument extends CoverageMetricDocument {
106
+ metric_type: "coverage-cobertura";
107
107
  coverage_format: "cobertura" | "opencover" | (string & {});
108
108
  }
109
109
  export interface JunitMetricDocument extends BaseMetricDocument {
@@ -128,7 +128,7 @@ export interface PipelineMetricDocument extends BaseMetricDocument {
128
128
  start_time: string | null;
129
129
  stage_name: string | null;
130
130
  }
131
- export type NormalizedDocument = BiomeMetricDocument | EslintMetricDocument | LighthouseMetricDocument | CoverageJsMetricDocument | SarifMetricDocument | DotnetCoverageMetricDocument | JunitMetricDocument | MetaMetricDocument | PipelineMetricDocument;
131
+ export type NormalizedDocument = BiomeMetricDocument | EslintMetricDocument | LighthouseMetricDocument | CoverageJsMetricDocument | SarifMetricDocument | CoberturaCoverageMetricDocument | JunitMetricDocument | MetaMetricDocument | PipelineMetricDocument;
132
132
  export interface CommonMetadata {
133
133
  repo: string;
134
134
  category: string | null;
@@ -0,0 +1 @@
1
+ export declare function formatBytes(bytes: number): string;
@@ -0,0 +1,11 @@
1
+ const UNITS = ["B", "kB", "MB", "GB"];
2
+ export function formatBytes(bytes) {
3
+ let value = bytes;
4
+ let unitIndex = 0;
5
+ while (value >= 1000 && unitIndex < UNITS.length - 1) {
6
+ value /= 1000;
7
+ unitIndex++;
8
+ }
9
+ const formatted = unitIndex === 0 ? value.toString() : value.toFixed(1).replace(/\.0$/, "");
10
+ return `${formatted} ${UNITS[unitIndex]}`;
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualink",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Collect, normalize, and relay code quality metrics from CI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,17 +17,17 @@
17
17
  "node": ">=22"
18
18
  },
19
19
  "dependencies": {
20
- "citty": "^0.2.1",
21
- "fast-xml-parser": "^5.4.2"
20
+ "citty": "^0.2.2",
21
+ "fast-xml-parser": "^5.7.3"
22
22
  },
23
23
  "devDependencies": {
24
- "@biomejs/biome": "^2.4.5",
25
- "@types/node": "^25.3.3",
26
- "@vitest/coverage-v8": "^4.0.18",
27
- "git-cliff": "^2.12.0",
24
+ "@biomejs/biome": "^2.4.14",
25
+ "@types/node": "^25.6.0",
26
+ "@vitest/coverage-v8": "^4.1.5",
27
+ "git-cliff": "^2.13.1",
28
28
  "ts-node": "^10.9.2",
29
- "typescript": "^5.9.3",
30
- "vitest": "^4.0.18"
29
+ "typescript": "^6.0.3",
30
+ "vitest": "^4.1.5"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -1,12 +0,0 @@
1
- import { collectCoverageDotnet } from "../../collectors/coverage-dotnet.js";
2
- import { createCollectorCommand } from "../command-factory.js";
3
- import { loadTextInput } from "../load-input.js";
4
- export const coverageDotnetCommand = createCollectorCommand({
5
- name: "coverage-dotnet",
6
- description: "Collect .NET coverage metrics and relay them",
7
- async collect(args, metadata) {
8
- const input = await loadTextInput(args);
9
- const documents = collectCoverageDotnet(input, metadata);
10
- return { metricType: "coverage-dotnet", documents };
11
- },
12
- });
@@ -1,2 +0,0 @@
1
- import type { CommonMetadata, DotnetCoverageMetricDocument } from "../types.js";
2
- export declare function collectCoverageDotnet(xmlInput: string, metadata: CommonMetadata): DotnetCoverageMetricDocument[];