@scalequality/check 0.5.4 → 0.5.6

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
@@ -19,13 +19,26 @@ and **Turn on continuous measurement**. Store the token as a CI secret named
19
19
  | `--token` | `SQ_TOKEN` | — | Your SQ token (required) |
20
20
  | `--project` | `SQ_PROJECT` | — | ScaleQuality project id (required) |
21
21
  | `--host` | `SQ_HOST` | `https://app.scalequality.io` | API host |
22
- | `--timeout` | — | `600` | Max seconds to wait for the measurement |
22
+ | `--timeout` | — | `600` | Max seconds to wait for the measurement, and separately for a measurement of the same project already running |
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
+ | `--strict` | `SQ_STRICT` | off | Fail the step (exit `2`) when the run could not be measured |
25
26
  | `--dry-run` | — | off | Measure locally and validate the package without uploading; no token needed |
26
27
  | `--artifact <path>` | `SQ_ARTIFACTS` | — | Scan a binary this job built for its components, licenses and CVEs (needs trivy); repeatable |
27
28
 
28
- Exit codes: `0` passed (or track-only), `1` below the cutoff or a license blocked by your policy, `2` error.
29
+ Exit codes:
30
+
31
+ | Code | When |
32
+ |------|------|
33
+ | `0` | Measured and passed, measured in track-only mode, or **NOT MEASURED** (see below) |
34
+ | `1` | Measured: below your cutoff, or a new dependency with a license your organization blocks |
35
+ | `2` | NOT MEASURED with `--strict`, or `--dry-run` could not prepare the package |
36
+
37
+ Only a measurement fails the build. When a run cannot be measured, the step says
38
+ **NOT MEASURED** in the log, in a warning on the run (GitHub Actions, Azure
39
+ Pipelines), in the job summary and in `--json` (`"measured": false`, with
40
+ `origin` and `reason`), never says the gate passed, and exits `0`. Pass `--strict`
41
+ to fail the step instead.
29
42
 
30
43
  ## CI examples
31
44
 
@@ -225,3 +238,33 @@ licenses and their CVEs join the measurement. Needs trivy on PATH, like
225
238
  - run: npx @scalequality/check --artifact target/app.war
226
239
  env: { SQ_TOKEN: ${{ secrets.SQ_TOKEN }}, SQ_PROJECT: ${{ vars.SQ_PROJECT }} }
227
240
  ```
241
+
242
+ ## 0.5.6: only a measurement fails the build
243
+
244
+ Until 0.5.5 every run that could not be measured exited `2`, and a pipeline that
245
+ runs the step as is failed on it: ScaleQuality unreachable or answering 5xx, a
246
+ timeout, a failed measurement, an expired token, a lapsed plan, continuous turned
247
+ off, or a pull request from a fork or Dependabot that gets no secrets. Now only a
248
+ measured verdict fails the step (`1`). Everything else exits `0` and says
249
+ **NOT MEASURED**, with who has to act:
250
+
251
+ | `origin` | `reason` | What happened |
252
+ |----------|----------|---------------|
253
+ | `scalequality` | `unreachable`, `server_error`, `auth_unavailable`, `server_rejected` | The upload did not reach a working ScaleQuality (after three short retries for network errors, 429 and 5xx) |
254
+ | `scalequality` | `measurement_already_running` | A measurement of the same project kept running for longer than `--timeout` (the check waits for it first) |
255
+ | `scalequality` | `timeout`, `status_unavailable`, `run_not_found`, `no_run_id` | The measurement did not report back in time |
256
+ | `scalequality` | `measurement_failed` | The engine failed; `failureReason` says why |
257
+ | `scalequality` | `no_verdict` | The measurement finished without a score to hold the cutoff against (exited `1` before) |
258
+ | `scalequality` | `protocol_unconfirmed`, `durability_unconfirmed` | The server did not confirm the report this checkout sent |
259
+ | `configuration` | `missing_credentials` | No `--token`/`--project` (secrets do not reach fork or Dependabot pull requests) |
260
+ | `configuration` | `invalid_token`, `token_expired` | The token was refused or expired (tokens default to 90 days) |
261
+ | `configuration` | `subscription_required` | No active plan with continuous measurement |
262
+ | `configuration` | `continuous_not_enabled` | Continuous measurement is turned off for the project |
263
+ | `configuration` | `project_not_found`, `invalid_option` | The project id or an `--exclude` is wrong |
264
+ | `checkout` | `durability_not_collected`, `checkout_not_packaged`, `head_changed` | This job's checkout could not be measured or packaged (a shallow clone, for example); nothing was uploaded |
265
+
266
+ Each request has its own deadline, so a host that accepts the connection and never
267
+ answers no longer holds the job until the CI kills it. `--strict` (or
268
+ `SQ_STRICT=true`) restores exit `2` for all of the above, for teams that prefer to
269
+ fail closed. A license your organization blocks and a score below the cutoff still
270
+ exit `1`, with or without it.
package/bin/check.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // ScaleQuality continuous check. Uploads THIS CI checkout to ScaleQuality (re-measures
3
- // the code the runner already has, with a local 90-day durability report) and exits
4
- // non-zero when maturity is below the configured cutoff, so a CI step can fail the
5
- // build. Pure Node 18+ (global fetch/FormData/Blob), no npm dependencies.
3
+ // the code the runner already has, with a local 90-day durability report) and exits 1
4
+ // when the measurement says the gate did not pass (below the configured cutoff, or a
5
+ // license the organization blocks), so a CI step can fail the build. Only a measured
6
+ // verdict fails it: a run ScaleQuality could not measure exits 0 with a NOT MEASURED
7
+ // warning, unless --strict asks to fail closed. Pure Node 18+ (global
8
+ // fetch/FormData/Blob), no npm dependencies.
6
9
 
7
10
  import { spawnSync } from "node:child_process"
8
11
  import { readFileSync, existsSync, appendFileSync } from "node:fs"
9
12
  import { prepareCheckoutArchive, normalizeExcludes } from "./archive.mjs"
10
13
  import { measureDurability } from "./durability.mjs"
11
14
  import { stageCoverageReports } from "./coverage.mjs"
12
- import { ciContext } from "./ci.mjs"
15
+ import { ciContext, changedFiles } from "./ci.mjs"
13
16
 
14
17
  const args = process.argv.slice(2)
15
18
  const flag = (name) => {
@@ -35,8 +38,11 @@ Options:
35
38
  binary, .NET, wheel) for its components, their licenses and
36
39
  CVEs; repeatable. Needs trivy on PATH; skipped with a note if
37
40
  it is missing, never fails the build for it.
38
- --timeout <s> Max seconds to wait for the measurement (default 600)
41
+ --timeout <s> Max seconds to wait for the measurement, and separately for a
42
+ measurement of this project that is already running (default 600)
39
43
  --exclude <p> Exclude a generated path relative to the checkout; repeatable
44
+ --strict Fail the step (exit 2) when the run could not be measured.
45
+ Off by default (or env SQ_STRICT=true).
40
46
  --dry-run Measure locally and check the package; do not upload (no token needed)
41
47
  --json Print the raw result as JSON
42
48
  -h, --help Show this help
@@ -45,7 +51,17 @@ Pull requests that add a dependency whose license your company policy forbids
45
51
  are listed in the log and the job summary; the check fails for it only where
46
52
  your organization turned blocking on.
47
53
 
48
- Exit codes: 0 passed (or track-only), 1 below the cutoff or a blocked license, 2 error.`)
54
+ Only a measurement fails the build. When ScaleQuality cannot measure this run
55
+ (unreachable, a server error, a timeout, a failed measurement) or the platform
56
+ refuses it (an invalid or expired token, no active plan, continuous turned off
57
+ for the project), the step reports NOT MEASURED in the log, the job summary and
58
+ --json, never says the gate passed, and exits 0 so your build is not failed by
59
+ it. Pass --strict to fail the step instead.
60
+
61
+ Exit codes:
62
+ 0 passed, track-only, or NOT MEASURED
63
+ 1 measured: below the cutoff, or a license your organization blocks
64
+ 2 NOT MEASURED with --strict, or --dry-run could not prepare the package`)
49
65
  process.exit(0)
50
66
  }
51
67
 
@@ -59,15 +75,151 @@ const artifacts = [
59
75
  ...args.flatMap((arg, i) => (arg === "--artifact" && args[i + 1] ? [args[i + 1]] : [])),
60
76
  ...(process.env.SQ_ARTIFACTS ? process.env.SQ_ARTIFACTS.split(",").map((s) => s.trim()).filter(Boolean) : []),
61
77
  ]
62
- const timeoutMs = Math.max(30, Number(flag("--timeout") || 600)) * 1000
78
+ const strict = has("--strict") || /^(1|true|yes)$/i.test(process.env.SQ_STRICT || "")
79
+ // Test-only: shrinks every wait (retries, poll ladder, timeouts) so the suite can walk
80
+ // the slow paths in milliseconds. A pipeline never sets it.
81
+ const timeScale = Number(process.env.SQ_CHECK_TIME_SCALE) > 0 ? Number(process.env.SQ_CHECK_TIME_SCALE) : 1
82
+ const scaled = (ms) => Math.max(1, Math.round(ms * timeScale))
83
+ const timeoutSeconds = Math.max(30, Number(flag("--timeout")) || 600)
84
+ const timeoutMs = scaled(timeoutSeconds * 1000)
63
85
 
64
86
  const base = `${host}/api/insights/continuous`
65
87
  const log = (m) => { if (!jsonOut) console.log(m) }
66
- const fail = (m) => { console.error(`ScaleQuality: ${m}`); process.exit(2) }
88
+ // Progress that must not land in the --json stdout.
89
+ const note = (m) => (jsonOut ? console.error : console.log)(m)
67
90
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
68
91
 
92
+ /**
93
+ * A run that ended without a measured verdict. Only a measurement fails the build
94
+ * (exit 1); everything that kept it from happening exits 0 with a NOT MEASURED
95
+ * warning, or 2 with --strict, whoever caused it. The origin says who, so the log
96
+ * points at the right fix:
97
+ * - scalequality: our side. Unreachable, a 5xx, a timeout, a failed measurement,
98
+ * no score to hold the cutoff against, a protocol the server did not confirm, a
99
+ * measurement of the same project that kept running.
100
+ * - configuration: what the platform holds for this pipeline (the token, the plan,
101
+ * continuous turned on, the project id), or a secret the CI did not hand to the job.
102
+ * - checkout: this job's own checkout could not be packaged (Git history, the
103
+ * archive limit, a file that changed while it was read).
104
+ * A token that expired after its 90 days, a plan that lapsed or a measurement
105
+ * turned off in the platform must not turn every build of the customer red, and a
106
+ * pull request from a fork or from Dependabot gets no secrets at all.
107
+ */
108
+ class NotMeasured extends Error {
109
+ constructor(origin, reason, message, details = {}) {
110
+ super(message)
111
+ this.origin = origin
112
+ this.reason = reason
113
+ this.details = details
114
+ }
115
+ }
116
+ const notMeasured = (origin, reason, message, details) => { throw new NotMeasured(origin, reason, message, details) }
117
+
118
+ /**
119
+ * GitHub Actions shows it on the run and on the pull request; Azure Pipelines on the
120
+ * run summary. Both read these commands from stdout, which --json keeps for the result.
121
+ */
122
+ function annotate(level, title, text) {
123
+ const write = jsonOut ? console.error : console.log
124
+ const data = (s) => String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")
125
+ const property = (s) => data(s).replace(/:/g, "%3A").replace(/,/g, "%2C")
126
+ if (process.env.GITHUB_ACTIONS === "true") write(`::${level} title=${property(title)}::${data(text)}`)
127
+ else if (process.env.TF_BUILD) write(`##vso[task.logissue type=${level}]${String(text).replace(/[\r\n]+/g, " ")}`)
128
+ }
129
+
130
+ function finishNotMeasured({ origin, reason, message, details = {} }) {
131
+ if (dryRun) {
132
+ console.error(`ScaleQuality: ${message}`)
133
+ process.exit(2)
134
+ }
135
+ const exitCode = strict ? 2 : 0
136
+ const outcome = strict
137
+ ? "The gate was not evaluated for this commit. Failing the step because --strict is set."
138
+ : "The gate was not evaluated for this commit, so this step exits 0: a run ScaleQuality could not measure does not fail your build. Pass --strict to fail it instead."
139
+ console.error(`ScaleQuality: NOT MEASURED. ${message}`)
140
+ console.error(`ScaleQuality: ${outcome}`)
141
+ annotate(strict ? "error" : "warning", "ScaleQuality: not measured", `${message} ${outcome}`)
142
+ if (process.env.GITHUB_STEP_SUMMARY) {
143
+ const md = ["### ScaleQuality: not measured", "", message, "", outcome, ""].join("\n")
144
+ try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, md) } catch { /* the log already says it */ }
145
+ }
146
+ if (jsonOut) console.log(JSON.stringify({ measured: false, outcome: "not_measured", origin, reason, message, strict, exitCode, ...details }))
147
+ process.exit(exitCode)
148
+ }
149
+
69
150
  if (!dryRun && (!token || !project)) {
70
- fail("--token and --project are required (or env SQ_TOKEN / SQ_PROJECT). See --help.")
151
+ finishNotMeasured(new NotMeasured("configuration", "missing_credentials",
152
+ "--token and --project are required (or env SQ_TOKEN / SQ_PROJECT). On a pull request from a fork or from Dependabot the CI does not hand repository secrets to the job. See --help."))
153
+ }
154
+
155
+ /** The `error` code ScaleQuality puts in a JSON error body, when there is one. */
156
+ function errorCode(text) {
157
+ try { return JSON.parse(text)?.error } catch { return typeof text === "string" && text.includes("measurement_already_running") ? "measurement_already_running" : undefined }
158
+ }
159
+ const detail = (text) => {
160
+ const s = String(text || "").replace(/\s+/g, " ").trim().slice(0, 200)
161
+ return s ? `: ${s}` : ""
162
+ }
163
+
164
+ /** The token refusals that are the pipeline's to fix, as opposed to our auth store failing (auth_failed). */
165
+ function tokenRefused(code) {
166
+ return code === "token_expired"
167
+ ? ["token_expired", "The SQ token expired. Issue a new one in ScaleQuality, update the SQ_TOKEN secret, then revoke the old token."]
168
+ : ["invalid_token", "The SQ token was refused (invalid or revoked). Issue a new one in ScaleQuality and update the SQ_TOKEN secret."]
169
+ }
170
+
171
+ /**
172
+ * One request with its own deadline: a host that accepts the connection and then
173
+ * never answers must not hold the job until the CI kills it (a red build again).
174
+ */
175
+ async function request(url, init, limitMs) {
176
+ try {
177
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(Math.max(1, limitMs)) })
178
+ return { res, text: res.ok ? undefined : await res.text().catch(() => "") }
179
+ } catch (error) {
180
+ return { error }
181
+ }
182
+ }
183
+
184
+ /** What an upload that was not accepted means, and whether waiting can change it. */
185
+ function uploadProblem(status, text) {
186
+ const code = errorCode(text)
187
+ if (status === 401 && code === "auth_failed") return { origin: "scalequality", reason: "auth_unavailable", message: "ScaleQuality could not check the token (401 auth_failed).", retry: "transient" }
188
+ if (status === 401) { const [reason, message] = tokenRefused(code); return { origin: "configuration", reason, message } }
189
+ if (status === 402) return { origin: "configuration", reason: "subscription_required", message: "Continuous measurement needs an active subscription for this organization." }
190
+ if (status === 404 && code === "project_not_found") return { origin: "configuration", reason: "project_not_found", message: `Project ${project} was not found in this token's organization. Compare --project with the id in the URL of the repository's diagnosis.` }
191
+ if (status === 409 && code === "measurement_already_running") return { origin: "scalequality", reason: "measurement_already_running", message: `Another measurement of this project was still running after ${timeoutSeconds}s of waiting for it.`, retry: "busy" }
192
+ if (status === 409) return { origin: "configuration", reason: "continuous_not_enabled", message: "Continuous measurement is turned off for this project in ScaleQuality. Turn it on there, or remove this step." }
193
+ if (status === 429 || status >= 500) return { origin: "scalequality", reason: "server_error", message: `ScaleQuality answered ${status}${detail(text)}.`, retry: "transient" }
194
+ return { origin: "scalequality", reason: "server_rejected", message: `ScaleQuality did not accept the report (${status}${detail(text)}).` }
195
+ }
196
+
197
+ // A deploy rolling over or a dropped connection lasts seconds; three short retries
198
+ // cover it without hiding a real outage behind minutes of waiting.
199
+ const RETRY_MS = [2000, 5000, 10000]
200
+ // A measurement of the same project already running (another push, a scheduled
201
+ // re-measurement) usually ends in seconds: wait for it instead of skipping this one.
202
+ const BUSY_MS = 10000
203
+
204
+ async function upload(form, auth) {
205
+ const busyUntil = Date.now() + timeoutMs
206
+ let retries = 0
207
+ for (;;) {
208
+ const { res, text, error } = await request(`${base}/report/upload`, { method: "POST", headers: auth, body: form }, timeoutMs)
209
+ if (res?.ok) return res
210
+ const problem = error
211
+ ? { origin: "scalequality", reason: "unreachable", message: `Could not reach ${host} (${error.message}).`, retry: "transient" }
212
+ : uploadProblem(res.status, text)
213
+ const wait = problem.retry === "busy" ? scaled(BUSY_MS)
214
+ : problem.retry === "transient" && retries < RETRY_MS.length ? scaled(RETRY_MS[retries++]) : null
215
+ if (wait == null || (problem.retry === "busy" && Date.now() + wait > busyUntil)) {
216
+ notMeasured(problem.origin, problem.reason, problem.message, res ? { httpStatus: res.status } : {})
217
+ }
218
+ note(problem.retry === "busy"
219
+ ? "ScaleQuality: another measurement of this project is running; waiting for it to finish before sending this one."
220
+ : `ScaleQuality: ${problem.message} Trying again in ${Math.round(wait / 1000)}s.`)
221
+ await sleep(wait)
222
+ }
71
223
  }
72
224
 
73
225
  /**
@@ -216,21 +368,41 @@ function reportLicenses(license) {
216
368
 
217
369
  async function main() {
218
370
  const auth = { Authorization: `Bearer ${token}` }
219
- const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
220
- const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
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.")
222
- const coverage = await stageCoverageReports({ log })
223
- let packed, buf
371
+ let excludes
372
+ try {
373
+ excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
374
+ } catch (error) {
375
+ notMeasured("configuration", "invalid_option", error?.message || String(error))
376
+ }
377
+ // The collector logs why it stopped; the warning and the job summary carry it too,
378
+ // since an annotation is read without the log line above it.
379
+ let durabilityProblem
380
+ const durabilityLog = (message) => {
381
+ const stopped = /^ScaleQuality: Git durability not measured \u2014 (.+?)\. No Git history was uploaded\.$/.exec(message)
382
+ if (stopped) durabilityProblem = stopped[1]
383
+ ;(jsonOut ? console.error : log)(message)
384
+ }
385
+ const durabilityReport = await measureDurability({ root: process.cwd(), log: durabilityLog })
386
+ if (!durabilityReport) {
387
+ const why = durabilityProblem ? ` (${durabilityProblem})` : ""
388
+ const shallow = /shallow/i.test(durabilityProblem || "") ? " Check the repository out with full history: fetch-depth 0, GIT_DEPTH 0, clone depth full or fetchDepth 0, depending on the CI." : ""
389
+ notMeasured("checkout", "durability_not_collected", `Git durability collection failed${why}. No upload was sent and no new score was submitted.${shallow || " Resolve the Git error before rerunning this step."}`)
390
+ }
391
+ let coverage, packed, buf
224
392
  try {
393
+ coverage = await stageCoverageReports({ log })
225
394
  packed = prepareCheckoutArchive({ root: process.cwd(), coverageRoot: coverage.root, durabilityReport, excludes, log })
226
395
  if (durabilityReport) {
227
396
  const head = spawnSync("git", ["--no-optional-locks", "-C", process.cwd(), "rev-parse", "--verify", "HEAD"], { encoding: "utf8", timeout: 10000 })
228
- if (head.error || head.status !== 0 || head.stdout.trim() !== durabilityReport.headSha) throw new Error("Git HEAD changed during measurement or packaging. No upload was sent; rerun after the checkout is stable.")
397
+ if (head.error || head.status !== 0 || head.stdout.trim() !== durabilityReport.headSha) notMeasured("checkout", "head_changed", "Git HEAD changed during measurement or packaging. No upload was sent; rerun after the checkout is stable.")
229
398
  }
230
399
  if (!dryRun) buf = readFileSync(packed.archivePath)
400
+ } catch (error) {
401
+ if (error instanceof NotMeasured) throw error
402
+ notMeasured("checkout", "checkout_not_packaged", error?.message || String(error))
231
403
  } finally {
232
404
  packed?.cleanup()
233
- coverage.cleanup()
405
+ coverage?.cleanup()
234
406
  }
235
407
  const localCheckout = { version: 1, history: packed.history, archiveBytes: packed.bytes, durability: durabilityReport ? { source: "ci", windowDays: 90, settleDays: 14, headSha: durabilityReport.headSha, measuredAt: durabilityReport.measuredAt, engineCommit: durabilityReport.engineCommit } : undefined }
236
408
  const limitation = "ScaleQuality: historical secret scanning and commit-convention signals are not included in this checkout report."
@@ -256,21 +428,16 @@ async function main() {
256
428
  if (ci.headRef) form.append("ciHeadRef", ci.headRef)
257
429
  if (ci.pullRequest) form.append("ciPullRequest", String(ci.pullRequest))
258
430
  }
259
- const r = await fetch(`${base}/report/upload`, {
260
- method: "POST",
261
- headers: auth,
262
- body: form,
263
- }).catch((e) => fail(`could not reach ${host} (${e.message})`))
264
- if (!r.ok) {
265
- const body = await r.text().catch(() => "")
266
- if (r.status === 401) fail("invalid token.")
267
- if (r.status === 402) fail("continuous measurement needs an active subscription.")
268
- if (r.status === 409) fail(body.includes("measurement_already_running") ? "another measurement is already running for this project. Wait for it to finish and rerun this step." : "continuous is not enabled for this project. Turn it on in ScaleQuality.")
269
- fail(`report failed (${r.status}) ${body}`)
270
- }
271
- const { runId } = await r.json()
272
- if (!runId) fail("no run id returned.")
431
+ // Which files the change touched: the other modules of a monorepo it reached are measured again too.
432
+ const touched = changedFiles()
433
+ if (touched) form.append("ciChangedFiles", touched.join("\n"))
434
+ const r = await upload(form, auth)
435
+ const accepted = await r.json().catch(() => null)
436
+ const runId = accepted?.runId
437
+ const modules = accepted?.modules
438
+ if (!runId) notMeasured("scalequality", "no_run_id", "ScaleQuality accepted the report but returned no measurement id.")
273
439
  log(`ScaleQuality: measuring ${project} …`)
440
+ if (Array.isArray(modules) && modules.length) log(`ScaleQuality: also measuring the ${modules.length === 1 ? "module" : `${modules.length} modules`} this change touched: ${modules.map((m) => m.path || "whole repository").join(", ")}`)
274
441
 
275
442
  // The poll interval is part of the measured gate time, not just politeness. A flat
276
443
  // 5s quantizes every verdict to a 5s boundary: an analysis that finishes in 7s is
@@ -286,39 +453,68 @@ async function main() {
286
453
  const POLL_MS = [1200, 1200, 900, 700, 700, 700, 700, 700, 1000, 1500, 2000, 3000]
287
454
  const deadline = Date.now() + timeoutMs
288
455
  let last
456
+ // A status read that fails is retried on the next tick until the deadline: one
457
+ // dropped read must not cost a measurement that is about to finish.
458
+ let lastProblem
289
459
  let poll = 0
290
460
  while (Date.now() < deadline) {
291
- await sleep(POLL_MS[Math.min(poll++, POLL_MS.length - 1)])
292
- const sr = await fetch(`${base}/report/${runId}`, { headers: auth }).catch((e) => fail(`status check failed (${e.message})`))
293
- if (!sr.ok) { const b = await sr.text().catch(() => ""); fail(`status failed (${sr.status}) ${b}`) }
294
- last = await sr.json()
461
+ await sleep(scaled(POLL_MS[Math.min(poll++, POLL_MS.length - 1)]))
462
+ const { res, text, error } = await request(`${base}/report/${runId}`, { headers: auth }, scaled(30000))
463
+ if (error) { lastProblem = `could not reach ${host} (${error.message})`; continue }
464
+ if (!res.ok) {
465
+ const code = errorCode(text)
466
+ if (res.status === 401 && code !== "auth_failed") { const [reason, message] = tokenRefused(code); notMeasured("configuration", reason, message, { runId, httpStatus: 401 }) }
467
+ if (res.status === 404) notMeasured("scalequality", "run_not_found", `ScaleQuality no longer finds measurement ${runId}.`, { runId, httpStatus: 404 })
468
+ lastProblem = `ScaleQuality answered ${res.status}${detail(text)}`
469
+ continue
470
+ }
471
+ const read = await res.json().catch(() => null)
472
+ if (!read || typeof read !== "object") { lastProblem = "ScaleQuality answered with an unreadable status"; continue }
473
+ last = read
474
+ lastProblem = undefined
295
475
  if (last.status === "COMPLETED" || last.status === "FAILED") break
296
476
  if (!jsonOut) process.stdout.write(".")
297
477
  }
298
478
  if (!jsonOut) process.stdout.write("\n")
299
479
 
300
- if (!last || (last.status !== "COMPLETED" && last.status !== "FAILED")) fail("timed out waiting for the measurement.")
301
- if (last.status === "FAILED") fail(`measurement failed: ${last.failureReason || "unknown"}`)
302
- if (last.checkout?.version !== 1) fail("the server did not confirm the checkout report protocol. No passing gate is claimed; update to the compatible continuous-measurement server release.")
480
+ if (!last || (last.status !== "COMPLETED" && last.status !== "FAILED")) {
481
+ if (lastProblem) notMeasured("scalequality", "status_unavailable", `Could not read the measurement status before the ${timeoutSeconds}s timeout (last attempt: ${lastProblem}).`, { runId })
482
+ notMeasured("scalequality", "timeout", `Timed out after ${timeoutSeconds}s waiting for measurement ${runId}. Raise --timeout if this repository needs longer.`, { runId, status: last?.status ?? null })
483
+ }
484
+ if (last.status === "FAILED") notMeasured("scalequality", "measurement_failed", `The measurement failed on ScaleQuality's side (${last.failureReason || "unknown"}).`, { runId, failureReason: last.failureReason ?? null })
485
+ if (last.checkout?.version !== 1) notMeasured("scalequality", "protocol_unconfirmed", "The server did not confirm the checkout report protocol. No passing gate is claimed; the server needs the compatible continuous-measurement release.", { runId })
303
486
  if (durabilityReport && (last.checkout?.durability?.source !== "ci" || last.checkout.durability.windowDays !== 90 || last.checkout.durability.settleDays !== 14 || last.checkout.durability.headSha !== durabilityReport.headSha || last.checkout.durability.measuredAt !== durabilityReport.measuredAt || last.checkout.durability.engineCommit !== durabilityReport.engineCommit)) {
304
- fail("the server did not confirm this checkout's 90-day durability report. No passing gate is claimed; the server needs the compatible continuous-measurement release.")
487
+ notMeasured("scalequality", "durability_unconfirmed", "The server did not confirm this checkout's 90-day durability report. No passing gate is claimed; the server needs the compatible continuous-measurement release.", { runId })
488
+ }
489
+
490
+ // Exit 1 only for what the measurement said: below the cutoff, or a license the
491
+ // organization blocks. A completed run with no score to hold the cutoff against
492
+ // (gateResult UNKNOWN) or with no verdict at all was not measured, and says so.
493
+ const licenseBlocked = last.license?.result === "BLOCKED"
494
+ const belowCutoff = last.gateResult === "FAILED"
495
+ || (typeof last.maturity === "number" && typeof last.cutoff === "number" && last.maturity < last.cutoff)
496
+ const gateFailed = last.passed === false && (belowCutoff || licenseBlocked)
497
+ if (typeof last.passed !== "boolean" || (last.passed === false && !gateFailed)) {
498
+ if (!jsonOut) reportLicenses(last.license)
499
+ notMeasured("scalequality", "no_verdict", last.cutoff != null && last.maturity == null
500
+ ? `The measurement finished without a maturity score, so the cutoff ${last.cutoff} could not be evaluated.`
501
+ : `The measurement finished without a gate verdict (${last.gateResult ?? "UNKNOWN"}).`, { runId, gateResult: last.gateResult ?? null })
305
502
  }
306
503
 
307
504
  if (jsonOut) {
308
- console.log(JSON.stringify(last))
505
+ console.log(JSON.stringify({ ...last, measured: true, outcome: gateFailed ? "gate_failed" : last.cutoff == null ? "track_only" : "passed" }))
309
506
  } else {
310
507
  const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
311
508
  log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
312
509
  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.")
510
+ if (gateFailed && belowCutoff) console.error("ScaleQuality: below the line, failing the build.")
511
+ else if (gateFailed) console.error("ScaleQuality: a forbidden license, failing the build.")
318
512
  else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
319
513
  else log("ScaleQuality: passed.")
320
514
  }
321
- process.exit(last.passed === false ? 1 : 0)
515
+ process.exit(gateFailed ? 1 : 0)
322
516
  }
323
517
 
324
- main().catch((e) => fail(e?.message || String(e)))
518
+ main().catch((error) => finishNotMeasured(error instanceof NotMeasured
519
+ ? error
520
+ : new NotMeasured("scalequality", "check_error", `The check stopped unexpectedly (${error?.message || String(error)}).`)))
package/bin/ci.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { readFileSync } from "node:fs"
1
3
  // Where a CI run happened, read from the provider's own variables.
2
4
 
3
5
  /**
@@ -16,3 +18,31 @@ export function ciContext(env = process.env) {
16
18
  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
19
  return null
18
20
  }
21
+
22
+ /**
23
+ * The files this change touched, so ScaleQuality measures again only the modules of a monorepo it reached
24
+ * (Erik, 09/10/2026). A pull request compares against its base branch; a push against the commit before it
25
+ * (or the previous one when the CI does not say). Null when git cannot tell: the server then measures them all.
26
+ */
27
+ export function changedFiles(env = process.env, cwd = process.cwd(), run = spawnSync) {
28
+ const git = (...args) => {
29
+ const r = run("git", ["--no-optional-locks", "-C", cwd, ...args], { encoding: "utf8", timeout: 20000, maxBuffer: 32 * 1024 * 1024 })
30
+ return r.error || r.status !== 0 ? null : String(r.stdout)
31
+ }
32
+ const zero = (sha) => !sha || /^0+$/.test(sha)
33
+ const strip = (v) => (v ? String(v).replace(/^refs\/heads\//, "") : "")
34
+ let range = null
35
+ const prBase = env.GITHUB_BASE_REF || env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME || strip(env.SYSTEM_PULLREQUEST_TARGETBRANCH) || env.BITBUCKET_PR_DESTINATION_BRANCH
36
+ if (env.CI_MERGE_REQUEST_DIFF_BASE_SHA) range = [`${env.CI_MERGE_REQUEST_DIFF_BASE_SHA}...HEAD`]
37
+ else if (prBase) range = [`origin/${prBase}...HEAD`]
38
+ else {
39
+ let before = env.CI_COMMIT_BEFORE_SHA || null
40
+ if (!before && env.GITHUB_EVENT_PATH) {
41
+ try { before = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")).before || null } catch { before = null }
42
+ }
43
+ range = zero(before) ? ["HEAD~1", "HEAD"] : [before, "HEAD"]
44
+ }
45
+ const out = git("diff", "--name-only", ...range)
46
+ if (out === null) return null
47
+ return out.split("\n").map((f) => f.trim()).filter(Boolean).slice(0, 20000)
48
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.5.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.",
3
+ "version": "0.5.6",
4
+ "description": "ScaleQuality continuous code-maturity check for CI. Re-measures your repo on every build and fails the pipeline below your maturity line, never for a run it could not measure.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "check": "bin/check.mjs",