@scalequality/check 0.5.2 → 0.5.4

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
@@ -23,8 +23,9 @@ and **Turn on continuous measurement**. Store the token as a CI secret named
23
23
  | `--json` | — | off | Print the raw result as JSON |
24
24
  | `--exclude <path>` | — | — | Exclude a generated file/directory by literal checkout-relative path; repeatable |
25
25
  | `--dry-run` | — | off | Measure locally and validate the package without uploading; no token needed |
26
+ | `--artifact <path>` | `SQ_ARTIFACTS` | — | Scan a binary this job built for its components, licenses and CVEs (needs trivy); repeatable |
26
27
 
27
- Exit codes: `0` passed (or track-only), `1` below the cutoff, `2` error.
28
+ Exit codes: `0` passed (or track-only), `1` below the cutoff or a license blocked by your policy, `2` error.
28
29
 
29
30
  ## CI examples
30
31
 
@@ -95,7 +96,7 @@ is rejected locally before upload; it is not treated as a full 90-day measuremen
95
96
  - uses: actions/checkout@v4
96
97
  with:
97
98
  fetch-depth: 0
98
- - run: npx @scalequality/check@0.5.2 --project <projectId>
99
+ - run: npx @scalequality/check@0.5.3 --project <projectId>
99
100
  env:
100
101
  SQ_TOKEN: ${{ secrets.SQ_TOKEN }}
101
102
  ```
@@ -126,7 +127,7 @@ that you expect to measure.
126
127
 
127
128
  ```bash
128
129
  # Local verification; does not need a token and sends nothing to ScaleQuality.
129
- npx @scalequality/check@0.5.2 --dry-run
130
+ npx @scalequality/check@0.5.3 --dry-run
130
131
  ```
131
132
 
132
133
  The CLI checks compressed size before allocating the upload buffer or sending a
@@ -191,3 +192,36 @@ A valid report with zero settled or AI-attributed lines still proceeds. It does
191
192
  not invent 100% AI survival. Missing coverage alone does not block submission.
192
193
  A completed check below a configured maturity cutoff still exits with code 1;
193
194
  track-only completion still exits with code 0.
195
+
196
+ ## 0.5.3: complete coverage report collection
197
+
198
+ Coverage discovery and staging now process all matching reports incrementally,
199
+ including deep modules and individual reports larger than 25 MiB. Report bytes
200
+ and paths are preserved; the CLI no longer silently stops after 40 reports.
201
+ A file that changes or cannot be copied stops submission before any upload.
202
+ The existing 60 MiB compressed upload limit, Git durability calculation and
203
+ maturity gates are unchanged. This release requires the matching server reader
204
+ that accepts the complete staged report index.
205
+
206
+ ## 0.5.4: open source licenses in the pull request, and the binaries you build
207
+
208
+ **License gate.** Each run sends where it happened (GitHub Actions, GitLab CI,
209
+ Azure Pipelines or Bitbucket Pipelines: the pull request's base and head
210
+ branches and its number). ScaleQuality compares the run's dependencies with the
211
+ base branch and lists every new dependency whose license your company policy
212
+ forbids, in the log and, on GitHub Actions, in the job summary. It is a warning
213
+ unless your organization turned on "Block the pull request" in Compliance ›
214
+ Licenses; then the step exits 1 until the package is removed or an Owner or Admin
215
+ accepts it with an exception.
216
+
217
+ **`--artifact <path>`** (repeatable, or `SQ_ARTIFACTS=a.jar,b.war`). Scans a
218
+ binary this job built (jar, war, ear, Go and Rust binaries with build metadata,
219
+ .NET, wheels) with `trivy rootfs`, so the components bundled inside it, their
220
+ licenses and their CVEs join the measurement. Needs trivy on PATH, like
221
+ `--image`; without it the step says so and the gate still runs.
222
+
223
+ ```yaml
224
+ - uses: aquasecurity/setup-trivy@v0.2.0
225
+ - run: npx @scalequality/check --artifact target/app.war
226
+ env: { SQ_TOKEN: ${{ secrets.SQ_TOKEN }}, SQ_PROJECT: ${{ vars.SQ_PROJECT }} }
227
+ ```
package/bin/check.mjs CHANGED
@@ -4,12 +4,12 @@
4
4
  // non-zero when maturity is below the configured cutoff, so a CI step can fail the
5
5
  // build. Pure Node 18+ (global fetch/FormData/Blob), no npm dependencies.
6
6
 
7
- import { tmpdir } from "node:os"
8
- import { join, dirname, relative, sep } from "node:path"
9
7
  import { spawnSync } from "node:child_process"
10
- import { readFileSync, writeFileSync, statSync, rmSync, readdirSync, mkdirSync, mkdtempSync, copyFileSync } from "node:fs"
8
+ import { readFileSync, existsSync, appendFileSync } from "node:fs"
11
9
  import { prepareCheckoutArchive, normalizeExcludes } from "./archive.mjs"
12
10
  import { measureDurability } from "./durability.mjs"
11
+ import { stageCoverageReports } from "./coverage.mjs"
12
+ import { ciContext } from "./ci.mjs"
13
13
 
14
14
  const args = process.argv.slice(2)
15
15
  const flag = (name) => {
@@ -31,13 +31,21 @@ Options:
31
31
  --image <ref> Also scan the container image this job just built, e.g.
32
32
  myapp:\${{ github.sha }}. Needs trivy on PATH; skipped with a
33
33
  note if it is missing, never fails the build for it.
34
+ --artifact <p> Also scan a binary this job built (jar, war, ear, Go or Rust
35
+ binary, .NET, wheel) for its components, their licenses and
36
+ CVEs; repeatable. Needs trivy on PATH; skipped with a note if
37
+ it is missing, never fails the build for it.
34
38
  --timeout <s> Max seconds to wait for the measurement (default 600)
35
39
  --exclude <p> Exclude a generated path relative to the checkout; repeatable
36
40
  --dry-run Measure locally and check the package; do not upload (no token needed)
37
41
  --json Print the raw result as JSON
38
42
  -h, --help Show this help
39
43
 
40
- Exit codes: 0 passed (or track-only), 1 below the cutoff, 2 error.`)
44
+ Pull requests that add a dependency whose license your company policy forbids
45
+ are listed in the log and the job summary; the check fails for it only where
46
+ your organization turned blocking on.
47
+
48
+ Exit codes: 0 passed (or track-only), 1 below the cutoff or a blocked license, 2 error.`)
41
49
  process.exit(0)
42
50
  }
43
51
 
@@ -47,6 +55,10 @@ const host = (flag("--host") || process.env.SQ_HOST || "https://app.scalequality
47
55
  const jsonOut = has("--json")
48
56
  const dryRun = has("--dry-run")
49
57
  const image = flag("--image") || process.env.SQ_IMAGE
58
+ const artifacts = [
59
+ ...args.flatMap((arg, i) => (arg === "--artifact" && args[i + 1] ? [args[i + 1]] : [])),
60
+ ...(process.env.SQ_ARTIFACTS ? process.env.SQ_ARTIFACTS.split(",").map((s) => s.trim()).filter(Boolean) : []),
61
+ ]
50
62
  const timeoutMs = Math.max(30, Number(flag("--timeout") || 600)) * 1000
51
63
 
52
64
  const base = `${host}/api/insights/continuous`
@@ -128,88 +140,78 @@ function slimReport(stdout, ref) {
128
140
  return json
129
141
  }
130
142
 
131
- // Coverage reports live in exactly the directories the archive excludes: JaCoCo
132
- // writes to target/ and build/, Istanbul to coverage/. So the one moment where a
133
- // REAL coverage number is sitting on disk — right after this job ran the tests —
134
- // was also the one moment we threw it away, and every Maven project came back
135
- // with no measured coverage even with a green suite behind it.
136
- //
137
- // The reports are copied into .sqreports/ under FLAT, indexed names, with an
138
- // index.json holding each one's original path. Mirroring the original directory
139
- // tree looked tidier and did not work: bsdtar does not anchor `--exclude=./target`
140
- // to the root, so a staged copy at `.scalequality/coverage/target/...` was dropped
141
- // on macOS while surviving under GNU tar. Measured, not assumed — and a layout
142
- // whose behaviour depends on which tar the runner ships is not a layout.
143
- // Every staged name here is `<n>-<basename>`, which no exclude pattern can match.
144
- const COVERAGE_NAMES = [
145
- { re: /^jacoco(TestReport)?\.xml$/i, format: "jacoco" },
146
- { re: /^lcov\.info$/i, format: "lcov" },
147
- { re: /^coverage-summary\.json$/i, format: "istanbul" },
148
- { re: /^coverage\.cobertura\.xml$/i, format: "cobertura" },
149
- { re: /^cobertura(-coverage)?\.xml$/i, format: "cobertura" },
150
- { re: /^coverage\.xml$/i, format: "cobertura" },
151
- { re: /^clover\.xml$/i, format: "clover" },
152
- { re: /^(coverage|cover)\.out$/i, format: "go" },
153
- ]
154
- const COVERAGE_SKIP = new Set(["node_modules", ".git", ".venv", "venv", "vendor", "__pycache__", ".sqreports"])
155
- const COVERAGE_STAGE = ".sqreports"
156
- const MAX_REPORT_BYTES = 25 * 1024 * 1024
157
- const MAX_REPORTS = 40
158
-
159
- function stageCoverageReports() {
160
- const root = process.cwd()
161
- const stagingRoot = mkdtempSync(join(tmpdir(), "sq-coverage-"))
162
- const stage = join(stagingRoot, COVERAGE_STAGE)
163
- // The staged copies must not outlive this process under ANY exit path. A
164
- // cancelled job or a network failure that left them behind would show up as
165
- // untracked files in the customer's checkout, and a pipeline that runs
166
- // `git diff --exit-code` after the gate would fail because of us.
167
- const clean = () => { try { rmSync(stagingRoot, { recursive: true, force: true }) } catch { /* best effort */ } }
168
- process.on("exit", clean)
169
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
170
- process.on(sig, () => { clean(); process.exit(130) })
143
+ /**
144
+ * Binaries this job built. A component bundled inside a jar or a Go binary is
145
+ * shipped like any dependency, but no lockfile lists it: the only place it can
146
+ * be read is the artifact, and the artifact only exists here. Same rules as the
147
+ * image: no trivy, a note and the gate still runs; a path that is not there is
148
+ * said, never skipped silently.
149
+ */
150
+ function scanArtifacts() {
151
+ if (!artifacts.length) return null
152
+ const probe = spawnSync("trivy", ["--version"], { encoding: "utf8" })
153
+ if (probe.error || probe.status !== 0) {
154
+ log("ScaleQuality: --artifact was given but trivy is not on PATH, so the artifact scan was skipped.")
155
+ log("ScaleQuality: add it before this step, e.g. `- uses: aquasecurity/setup-trivy@v0.2.0`.")
156
+ return null
157
+ }
158
+ const reports = []
159
+ for (const path of artifacts.slice(0, 20)) {
160
+ if (!existsSync(path)) { log(`ScaleQuality: artifact ${path} does not exist, skipped.`); continue }
161
+ const r = spawnSync("trivy", [
162
+ "rootfs", "--format", "json", "--quiet",
163
+ "--scanners", "vuln,license", "--list-all-pkgs",
164
+ "--severity", "CRITICAL,HIGH,MEDIUM",
165
+ path,
166
+ ], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 })
167
+ if (r.error || r.status !== 0 || !r.stdout) { log(`ScaleQuality: could not scan ${path}, continuing without it.`); continue }
168
+ const report = slimArtifact(r.stdout, path)
169
+ if (report) reports.push({ name: path.split("/").pop() || path, report })
171
170
  }
171
+ return reports.length ? JSON.stringify(reports) : null
172
+ }
172
173
 
173
- const index = []
174
- let bytes = 0
175
- const walk = (dir, depth) => {
176
- if (depth > 7 || index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
177
- let entries
178
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
179
- for (const e of entries) {
180
- if (index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
181
- const abs = join(dir, e.name)
182
- if (e.isDirectory()) {
183
- if (!COVERAGE_SKIP.has(e.name)) walk(abs, depth + 1)
184
- continue
185
- }
186
- if (!e.isFile()) continue
187
- const hit = COVERAGE_NAMES.find((c) => c.re.test(e.name))
188
- if (!hit) continue
189
- let size = 0
190
- try { size = statSync(abs).size } catch { continue }
191
- if (size === 0 || bytes + size > MAX_REPORT_BYTES) continue
192
- const staged = `${index.length}-${e.name}`
193
- try {
194
- mkdirSync(stage, { recursive: true })
195
- copyFileSync(abs, join(stage, staged))
196
- } catch { continue }
197
- bytes += size
198
- index.push({ file: staged, path: relative(root, abs).split(sep).join("/"), format: hit.format })
199
- }
174
+ /** Components with their licenses and the findings, nothing else: the server reads these fields. */
175
+ function slimArtifact(stdout, path) {
176
+ let d
177
+ try { d = JSON.parse(stdout) } catch { return null }
178
+ let packages = 0, vulns = 0
179
+ const out = {
180
+ ArtifactName: d.ArtifactName || path,
181
+ Results: (d.Results || []).map((res) => ({
182
+ Target: res.Target,
183
+ Class: res.Class,
184
+ Type: res.Type,
185
+ Packages: (res.Packages || []).map((p) => {
186
+ packages++
187
+ return { Name: p.Name, Version: p.Version, Licenses: Array.isArray(p.Licenses) ? p.Licenses : undefined, Identifier: p.Identifier?.PURL ? { PURL: p.Identifier.PURL } : undefined }
188
+ }),
189
+ Vulnerabilities: (res.Vulnerabilities || []).map((v) => {
190
+ vulns++
191
+ return { VulnerabilityID: v.VulnerabilityID, PkgName: v.PkgName, InstalledVersion: v.InstalledVersion, FixedVersion: v.FixedVersion, Severity: v.Severity, Title: typeof v.Title === "string" ? v.Title.slice(0, 300) : undefined }
192
+ }),
193
+ })),
200
194
  }
201
- walk(root, 0)
195
+ log(`ScaleQuality: scanned ${path}: ${packages} components, ${vulns} vulnerabilities`)
196
+ return JSON.stringify(out)
197
+ }
202
198
 
203
- if (index.length) {
204
- try {
205
- writeFileSync(join(stage, "index.json"), JSON.stringify({ version: 1, reports: index }))
206
- } catch { /* the archive just goes without them */ }
207
- const shown = index.slice(0, 3).map((r) => r.path).join(", ")
208
- log(`ScaleQuality: found ${index.length} coverage report(s) — ${shown}${index.length > 3 ? " …" : ""}`)
209
- } else {
210
- log("ScaleQuality: no coverage report found in this job. Run your tests with coverage before this step to get a measured %.")
199
+ /** The license gate, in the log and in the job summary (GitHub Actions). */
200
+ function reportLicenses(license) {
201
+ if (!license || license.result === "PASSED" || !license.newForbidden?.length) return
202
+ const total = license.newForbiddenTotal ?? license.newForbidden.length
203
+ const blocked = license.result === "BLOCKED"
204
+ const head = blocked
205
+ ? `ScaleQuality: ${total} new dependenc${total === 1 ? "y has" : "ies have"} a license your company policy forbids, so the pull request is blocked.`
206
+ : `ScaleQuality: ${total} new dependenc${total === 1 ? "y has" : "ies have"} a license your company policy forbids (warning; blocking is off for your organization).`
207
+ const lines = license.newForbidden.map((p) => ` - ${p.name}${p.version ? ` ${p.version}` : ""}: ${(p.licenses || []).join(", ")}${p.file ? ` (${p.file})` : ""}`)
208
+ ;(blocked ? console.error : console.log)([head, ...lines, " Remove it, or ask an Owner or Admin to accept it with an exception in ScaleQuality."].join("\n"))
209
+ if (process.env.GITHUB_STEP_SUMMARY) {
210
+ const md = [`### ${blocked ? "Blocked" : "Warning"}: forbidden license`, "", "| Package | Version | License | Found in |", "|---|---|---|---|",
211
+ ...license.newForbidden.map((p) => `| ${p.name} | ${p.version || ""} | ${(p.licenses || []).join(", ")} | ${p.file || ""} |`),
212
+ "", "Remove the dependency, or ask an Owner or Admin to accept it with an exception in ScaleQuality.", ""].join("\n")
213
+ try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, md) } catch { /* the log already says it */ }
211
214
  }
212
- return { stage, root: stagingRoot, count: index.length, cleanup: clean }
213
215
  }
214
216
 
215
217
  async function main() {
@@ -217,7 +219,7 @@ async function main() {
217
219
  const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
218
220
  const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
219
221
  if (!durabilityReport) fail("Git durability collection failed. No upload was sent and no new score was submitted. Resolve the Git error above before rerunning this step.")
220
- const coverage = stageCoverageReports()
222
+ const coverage = await stageCoverageReports({ log })
221
223
  let packed, buf
222
224
  try {
223
225
  packed = prepareCheckoutArchive({ root: process.cwd(), coverageRoot: coverage.root, durabilityReport, excludes, log })
@@ -245,6 +247,15 @@ async function main() {
245
247
  form.append("file", new Blob([buf]), "checkout.tar.gz")
246
248
  const containerReport = scanImage()
247
249
  if (containerReport) form.append("containerReport", containerReport)
250
+ const artifactReports = scanArtifacts()
251
+ if (artifactReports) form.append("artifactReports", artifactReports)
252
+ const ci = ciContext()
253
+ if (ci) {
254
+ form.append("ciProvider", ci.provider)
255
+ if (ci.baseRef) form.append("ciBaseRef", ci.baseRef)
256
+ if (ci.headRef) form.append("ciHeadRef", ci.headRef)
257
+ if (ci.pullRequest) form.append("ciPullRequest", String(ci.pullRequest))
258
+ }
248
259
  const r = await fetch(`${base}/report/upload`, {
249
260
  method: "POST",
250
261
  headers: auth,
@@ -298,7 +309,12 @@ async function main() {
298
309
  } else {
299
310
  const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
300
311
  log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
301
- if (last.passed === false) console.error("ScaleQuality: below the line — failing the build.")
312
+ reportLicenses(last.license)
313
+ const licenseBlocked = last.license?.result === "BLOCKED"
314
+ const maturityFailed = last.gateResult === "FAILED"
315
+ if (last.passed === false && maturityFailed) console.error("ScaleQuality: below the line — failing the build.")
316
+ else if (last.passed === false && licenseBlocked) console.error("ScaleQuality: a forbidden license, failing the build.")
317
+ else if (last.passed === false) console.error("ScaleQuality: the gate did not pass, failing the build.")
302
318
  else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
303
319
  else log("ScaleQuality: passed.")
304
320
  }
package/bin/ci.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // Where a CI run happened, read from the provider's own variables.
2
+
3
+ /**
4
+ * Where this run happened, from the CI's own variables: the pull request's base
5
+ * (what the license gate compares against), its head and number. Outside a pull
6
+ * request only the branch is known, and the base stays empty: never guessed.
7
+ */
8
+ export function ciContext(env = process.env) {
9
+ const strip = (v) => (v ? String(v).replace(/^refs\/heads\//, "") : null)
10
+ if (env.GITHUB_ACTIONS) {
11
+ const pr = /^refs\/pull\/(\d+)\//.exec(env.GITHUB_REF || "")
12
+ return { provider: "github", baseRef: env.GITHUB_BASE_REF || null, headRef: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || null, pullRequest: pr ? pr[1] : null }
13
+ }
14
+ if (env.GITLAB_CI) return { provider: "gitlab", baseRef: env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || null, headRef: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || env.CI_COMMIT_REF_NAME || null, pullRequest: env.CI_MERGE_REQUEST_IID || null }
15
+ if (env.TF_BUILD) return { provider: "azure", baseRef: strip(env.SYSTEM_PULLREQUEST_TARGETBRANCH), headRef: strip(env.SYSTEM_PULLREQUEST_SOURCEBRANCH || env.BUILD_SOURCEBRANCH), pullRequest: env.SYSTEM_PULLREQUEST_PULLREQUESTID || null }
16
+ if (env.BITBUCKET_BUILD_NUMBER) return { provider: "bitbucket", baseRef: env.BITBUCKET_PR_DESTINATION_BRANCH || null, headRef: env.BITBUCKET_BRANCH || null, pullRequest: env.BITBUCKET_PR_ID || null }
17
+ return null
18
+ }
@@ -0,0 +1,117 @@
1
+ import { constants, rmSync } from "node:fs"
2
+ import { open, opendir, mkdir, mkdtemp } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join, relative, sep } from "node:path"
5
+
6
+ const NAMES = [
7
+ { re: /^jacoco(TestReport)?\.xml$/i, format: "jacoco" },
8
+ { re: /^lcov\.info$/i, format: "lcov" },
9
+ { re: /^coverage-summary\.json$/i, format: "istanbul" },
10
+ { re: /^coverage\.cobertura\.xml$/i, format: "cobertura" },
11
+ { re: /^cobertura(-coverage)?\.xml$/i, format: "cobertura" },
12
+ { re: /^coverage\.xml$/i, format: "cobertura" },
13
+ { re: /^clover\.xml$/i, format: "clover" },
14
+ { re: /^(coverage|cover)\.out$/i, format: "go" },
15
+ ]
16
+ const SKIP = new Set(["node_modules", ".git", ".venv", "venv", "vendor", "__pycache__", ".sqreports"])
17
+ const STAGE = ".sqreports"
18
+
19
+ // Copy sequentially using a fixed buffer. Report count and uncompressed bytes
20
+ // do not determine which sources survive. The final compressed checkout still
21
+ // has the existing upload limit, checked by archive.mjs before any HTTP request.
22
+ async function copyReport(source, destination) {
23
+ let input, output
24
+ try {
25
+ input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
26
+ const before = await input.stat({ bigint: true })
27
+ if (!before.isFile() || before.size === 0n) throw new Error("report is empty or is not a regular file")
28
+ output = await open(destination, "wx")
29
+ const buffer = Buffer.allocUnsafe(64 * 1024)
30
+ let copied = 0n
31
+ while (true) {
32
+ const { bytesRead } = await input.read(buffer, 0, buffer.length, null)
33
+ if (!bytesRead) break
34
+ let written = 0
35
+ while (written < bytesRead) {
36
+ const { bytesWritten } = await output.write(buffer, written, bytesRead - written, null)
37
+ if (!bytesWritten) throw new Error("report copy made no progress")
38
+ written += bytesWritten
39
+ }
40
+ copied += BigInt(bytesRead)
41
+ if (copied > before.size) throw new Error("report changed during collection")
42
+ }
43
+ const after = await input.stat({ bigint: true })
44
+ if (copied !== before.size || after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) {
45
+ throw new Error("report changed during collection")
46
+ }
47
+ } finally {
48
+ await Promise.all([output?.close(), input?.close()])
49
+ }
50
+ }
51
+
52
+ /** Preserve every discovered original report in a flat v1 index. Flat names
53
+ * keep GNU/BSD tar exclusions from dropping nested build/target reports.
54
+ * Generated
55
+ * directories are scanned too: they are exactly where test runners write.
56
+ * No customer files are created or changed. A failed scan/copy/index never
57
+ * returns a partial set that could be presented as a complete measurement. */
58
+ export async function stageCoverageReports({ root = process.cwd(), log = () => {} } = {}) {
59
+ const stagingRoot = await mkdtemp(join(tmpdir(), "sq-coverage-"))
60
+ const stage = join(stagingRoot, STAGE)
61
+ const remove = () => { try { rmSync(stagingRoot, { recursive: true, force: true }) } catch { /* best effort on process exit */ } }
62
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"]
63
+ const onSignal = () => { remove(); process.exit(130) }
64
+ process.once("exit", remove)
65
+ for (const signal of signals) process.once(signal, onSignal)
66
+ const cleanup = () => {
67
+ process.removeListener("exit", remove)
68
+ for (const signal of signals) process.removeListener(signal, onSignal)
69
+ remove()
70
+ }
71
+ let index
72
+ let count = 0
73
+ const shown = []
74
+ let current = "."
75
+ const walk = async (directory) => {
76
+ current = relative(root, directory).split(sep).join("/") || "."
77
+ const entries = await opendir(directory)
78
+ for await (const entry of entries) {
79
+ const absolute = join(directory, entry.name)
80
+ if (entry.isDirectory()) {
81
+ if (!SKIP.has(entry.name)) await walk(absolute)
82
+ continue
83
+ }
84
+ if (!entry.isFile()) continue // Never follow report or directory symlinks.
85
+ const match = NAMES.find(candidate => candidate.re.test(entry.name))
86
+ if (!match) continue
87
+ current = relative(root, absolute).split(sep).join("/")
88
+ if (!index) {
89
+ await mkdir(stage)
90
+ index = await open(join(stage, "index.json"), "wx")
91
+ await index.writeFile('{"version":1,"reports":[')
92
+ }
93
+ const file = `${count}-${entry.name}`
94
+ await copyReport(absolute, join(stage, file))
95
+ await index.writeFile((count ? "," : "") + JSON.stringify({ file, path: current, format: match.format }))
96
+ count++
97
+ if (shown.length < 3) shown.push(current)
98
+ }
99
+ }
100
+ try {
101
+ await walk(root)
102
+ if (index) {
103
+ await index.writeFile("]}\n")
104
+ await index.close()
105
+ index = undefined
106
+ log(`ScaleQuality: found ${count} coverage report(s) — ${shown.join(", ")}${count > 3 ? " …" : ""}`)
107
+ } else {
108
+ log("ScaleQuality: no coverage report found in this job. Run your tests with coverage before this step to get a measured %.")
109
+ }
110
+ return { stage, root: stagingRoot, count, cleanup }
111
+ } catch (error) {
112
+ await index?.close().catch(() => {})
113
+ cleanup()
114
+ const reason = typeof error?.code === "string" ? error.code : error?.message || "I/O error"
115
+ throw new Error(`Coverage collection could not finish at ${JSON.stringify(current)} (${reason}). No upload was sent and no partial coverage was submitted.`)
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line.",
5
5
  "type": "module",
6
6
  "bin": {