@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 +37 -3
- package/bin/check.mjs +99 -83
- package/bin/ci.mjs +18 -0
- package/bin/coverage.mjs +117 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
+
log(`ScaleQuality: scanned ${path}: ${packages} components, ${vulns} vulnerabilities`)
|
|
196
|
+
return JSON.stringify(out)
|
|
197
|
+
}
|
|
202
198
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/coverage.mjs
ADDED
|
@@ -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