@scalequality/check 0.3.1 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +29 -0
  2. package/bin/check.mjs +90 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,3 +45,32 @@ scalequality:
45
45
 
46
46
  Any other CI: run the same `npx @scalequality/check` command, with `SQ_TOKEN`
47
47
  exposed as a secret/variable.
48
+
49
+ ## Measured coverage
50
+
51
+ Coverage is only real once the tests have run. If this job produces a coverage
52
+ report, the check finds it and ScaleQuality shows **measured line coverage**
53
+ instead of a directional estimate. Nothing to configure: run your tests with
54
+ coverage before this step.
55
+
56
+ ```yaml
57
+ - run: mvn verify # writes target/site/jacoco/jacoco.xml
58
+ - run: npx @scalequality/check --token "$SQ_TOKEN" --project <projectId>
59
+ ```
60
+
61
+ Recognised out of the box: JaCoCo (`jacoco.xml`), LCOV (`lcov.info`), Cobertura
62
+ (`coverage.xml`, `coverage.cobertura.xml`), Istanbul (`coverage-summary.json`),
63
+ Clover (`clover.xml`) and Go (`coverage.out`). Multi-module builds are summed.
64
+
65
+ The reports are copied into a temporary `.sqreports/` folder so they survive the
66
+ archive's build-output exclusions, and that folder is removed before the command
67
+ exits — including when the job is cancelled.
68
+
69
+ Report somewhere unusual? Point at it in `scalequality.properties` at the repo
70
+ root:
71
+
72
+ ```properties
73
+ sq.coverage.reportPaths=ops/reports/jacoco-aggregate.xml
74
+ ```
75
+
76
+ Full guide: https://scalequality.io/docs/continuous-measurement
package/bin/check.mjs CHANGED
@@ -5,9 +5,9 @@
5
5
  // build. Pure Node 18+ (global fetch/FormData/Blob), no npm dependencies.
6
6
 
7
7
  import { tmpdir } from "node:os"
8
- import { join } from "node:path"
8
+ import { join, dirname, relative, sep } from "node:path"
9
9
  import { spawnSync } from "node:child_process"
10
- import { readFileSync, existsSync, statSync, rmSync } from "node:fs"
10
+ import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync } from "node:fs"
11
11
 
12
12
  const args = process.argv.slice(2)
13
13
  const flag = (name) => {
@@ -123,8 +123,92 @@ function slimReport(stdout, ref) {
123
123
  return json
124
124
  }
125
125
 
126
+ // Coverage reports live in exactly the directories the archive excludes: JaCoCo
127
+ // writes to target/ and build/, Istanbul to coverage/. So the one moment where a
128
+ // REAL coverage number is sitting on disk — right after this job ran the tests —
129
+ // was also the one moment we threw it away, and every Maven project came back
130
+ // with no measured coverage even with a green suite behind it.
131
+ //
132
+ // The reports are copied into .sqreports/ under FLAT, indexed names, with an
133
+ // index.json holding each one's original path. Mirroring the original directory
134
+ // tree looked tidier and did not work: bsdtar does not anchor `--exclude=./target`
135
+ // to the root, so a staged copy at `.scalequality/coverage/target/...` was dropped
136
+ // on macOS while surviving under GNU tar. Measured, not assumed — and a layout
137
+ // whose behaviour depends on which tar the runner ships is not a layout.
138
+ // Every staged name here is `<n>-<basename>`, which no exclude pattern can match.
139
+ const COVERAGE_NAMES = [
140
+ { re: /^jacoco(TestReport)?\.xml$/i, format: "jacoco" },
141
+ { re: /^lcov\.info$/i, format: "lcov" },
142
+ { re: /^coverage-summary\.json$/i, format: "istanbul" },
143
+ { re: /^coverage\.cobertura\.xml$/i, format: "cobertura" },
144
+ { re: /^cobertura(-coverage)?\.xml$/i, format: "cobertura" },
145
+ { re: /^coverage\.xml$/i, format: "cobertura" },
146
+ { re: /^clover\.xml$/i, format: "clover" },
147
+ { re: /^(coverage|cover)\.out$/i, format: "go" },
148
+ ]
149
+ const COVERAGE_SKIP = new Set(["node_modules", ".git", ".venv", "venv", "vendor", "__pycache__", ".sqreports"])
150
+ const COVERAGE_STAGE = ".sqreports"
151
+ const MAX_REPORT_BYTES = 25 * 1024 * 1024
152
+ const MAX_REPORTS = 40
153
+
154
+ function stageCoverageReports() {
155
+ const root = process.cwd()
156
+ const stage = join(root, COVERAGE_STAGE)
157
+ rmSync(stage, { recursive: true, force: true })
158
+ // The staged copies must not outlive this process under ANY exit path. A
159
+ // cancelled job or a network failure that left them behind would show up as
160
+ // untracked files in the customer's checkout, and a pipeline that runs
161
+ // `git diff --exit-code` after the gate would fail because of us.
162
+ const clean = () => { try { rmSync(stage, { recursive: true, force: true }) } catch { /* best effort */ } }
163
+ process.on("exit", clean)
164
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
165
+ process.on(sig, () => { clean(); process.exit(130) })
166
+ }
167
+
168
+ const index = []
169
+ let bytes = 0
170
+ const walk = (dir, depth) => {
171
+ if (depth > 7 || index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
172
+ let entries
173
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
174
+ for (const e of entries) {
175
+ if (index.length >= MAX_REPORTS || bytes >= MAX_REPORT_BYTES) return
176
+ const abs = join(dir, e.name)
177
+ if (e.isDirectory()) {
178
+ if (!COVERAGE_SKIP.has(e.name)) walk(abs, depth + 1)
179
+ continue
180
+ }
181
+ const hit = COVERAGE_NAMES.find((c) => c.re.test(e.name))
182
+ if (!hit) continue
183
+ let size = 0
184
+ try { size = statSync(abs).size } catch { continue }
185
+ if (size === 0 || bytes + size > MAX_REPORT_BYTES) continue
186
+ const staged = `${index.length}-${e.name}`
187
+ try {
188
+ mkdirSync(stage, { recursive: true })
189
+ copyFileSync(abs, join(stage, staged))
190
+ } catch { continue }
191
+ bytes += size
192
+ index.push({ file: staged, path: relative(root, abs).split(sep).join("/"), format: hit.format })
193
+ }
194
+ }
195
+ walk(root, 0)
196
+
197
+ if (index.length) {
198
+ try {
199
+ writeFileSync(join(stage, "index.json"), JSON.stringify({ version: 1, reports: index }))
200
+ } catch { /* the archive just goes without them */ }
201
+ const shown = index.slice(0, 3).map((r) => r.path).join(", ")
202
+ log(`ScaleQuality: found ${index.length} coverage report(s) — ${shown}${index.length > 3 ? " …" : ""}`)
203
+ } else {
204
+ log("ScaleQuality: no coverage report found in this job. Run your tests with coverage before this step to get a measured %.")
205
+ }
206
+ return { stage, count: index.length }
207
+ }
208
+
126
209
  async function main() {
127
210
  const auth = { Authorization: `Bearer ${token}` }
211
+ const coverage = stageCoverageReports()
128
212
 
129
213
  // Upload THIS checkout (the runner already has the code) instead of asking the server
130
214
  // to clone the repo from outside — that needed a stored provider credential and failed
@@ -142,10 +226,14 @@ async function main() {
142
226
  "-C", process.cwd(), ".",
143
227
  ], { encoding: "utf8" })
144
228
  if (!(existsSync(archivePath) && statSync(archivePath).size > 0)) {
229
+ rmSync(coverage.stage, { recursive: true, force: true })
145
230
  fail(`could not archive the checkout (tar unavailable or failed). ${tr.error?.message || (tr.stderr || "").trim()}`)
146
231
  }
147
232
  const buf = readFileSync(archivePath)
148
233
  rmSync(archivePath, { force: true })
234
+ // The staged copies exist only to ride along in the archive. Leaving them
235
+ // behind would dirty the working tree of the job that ran us.
236
+ rmSync(coverage.stage, { recursive: true, force: true })
149
237
  const sizeMB = buf.length / (1024 * 1024)
150
238
  if (sizeMB > 60) fail(`checkout archive too large (${sizeMB.toFixed(0)}MB > 60MB limit). Reduce clone depth or exclude build output.`)
151
239
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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": {