@scalequality/check 0.5.0 → 0.5.2

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
@@ -87,15 +87,15 @@ source, coverage, and a bounded durability report (at most 1 MiB); it never uplo
87
87
  the raw `.git` object database. Years of old binary history therefore do not add
88
88
  to the archive size. It does not truncate or change your repository.
89
89
 
90
- Use a complete checkout for that measurement. Shallow history is reported as
91
- unavailable; it is not treated as a full 90-day measurement.
90
+ Use a complete checkout for that measurement. A shallow or incomplete checkout
91
+ is rejected locally before upload; it is not treated as a full 90-day measurement.
92
92
 
93
93
  ```yaml
94
94
  # GitHub Actions
95
95
  - uses: actions/checkout@v4
96
96
  with:
97
97
  fetch-depth: 0
98
- - run: npx @scalequality/check@0.5.0 --project <projectId>
98
+ - run: npx @scalequality/check@0.5.2 --project <projectId>
99
99
  env:
100
100
  SQ_TOKEN: ${{ secrets.SQ_TOKEN }}
101
101
  ```
@@ -109,8 +109,8 @@ clone:
109
109
 
110
110
  The current-code scanners still run on the server. Historical secret scanning and
111
111
  commit-convention analysis are not included in this report; the result shows those
112
- limits and identifies durability as reported by CI. CPU, time, malformed Git or
113
- report-size limits yield unavailable history, never a fabricated clean result.
112
+ limits and identifies durability as reported by CI. A failed Git collection or
113
+ invalid report stops the CLI before upload, without submitting a replacement score.
114
114
 
115
115
  Version 0.5.0 needs the compatible ScaleQuality server release. The CLI checks the
116
116
  server's acknowledgement of this exact report before declaring a passing gate.
@@ -126,9 +126,68 @@ that you expect to measure.
126
126
 
127
127
  ```bash
128
128
  # Local verification; does not need a token and sends nothing to ScaleQuality.
129
- npx @scalequality/check@0.5.0 --dry-run
129
+ npx @scalequality/check@0.5.2 --dry-run
130
130
  ```
131
131
 
132
132
  The CLI checks compressed size before allocating the upload buffer or sending a
133
133
  request. If current source alone cannot fit, it lists the largest included files
134
134
  and exits with an actionable error. No oversized upload is sent.
135
+
136
+
137
+ ## 0.5.1: Composer dependencies and large Git blame output
138
+
139
+ The CLI recognizes installed Composer dependencies using `composer.json` and a
140
+ valid `composer.lock`. It excludes the default `vendor/`, or a safe relative
141
+ `config.vendor-dir`, while preserving both files for dependency analysis. Nested
142
+ Composer projects are recognized too. A directory named `vendor` alone is not
143
+ sufficient evidence to exclude it. Ambiguous configuration, source paths that
144
+ overlap the dependency directory, and unavailable local package origins keep the
145
+ directory in the archive. The CLI reports which directories it excluded or kept.
146
+ It reads JSON only; it does not run Composer, plugins or scripts.
147
+
148
+ `node_modules/` and the existing generated-output directories remain excluded.
149
+ Coverage reports from application tests are staged separately before packaging.
150
+ Excluding installed dependencies does not certify that their locked versions are
151
+ free of vulnerabilities.
152
+
153
+ Git durability reads incremental blame ranges as a stream instead of buffering
154
+ all per-line metadata and source content. This preserves the native calculation,
155
+ 90-day window, 14-day settlement and tracked-file selection. It handles large
156
+ blame output without increasing the worker's memory or time budgets.
157
+
158
+ Archive exclusions, including `--exclude`, do not change Git durability's tracked
159
+ file scope. A tracked dependency can still contribute to the historical metric.
160
+ Changing that methodology requires a separate coordinated version; this patch
161
+ does not silently rewrite prior scores. Coverage may remain absent when the job
162
+ does not generate it; Git collection failures stop the check before upload.
163
+
164
+ Track-only completion now says `measurement completed (track only; no cutoff
165
+ configured)`. Its exit code remains zero; completion does not certify that every
166
+ dimension was measured or that a maturity threshold was passed.
167
+
168
+ The 60 MiB compressed-source limit still applies. A checkout with more than that
169
+ amount of first-party source or ambiguous generated assets is rejected locally;
170
+ this release does not truncate source to force a successful upload.
171
+
172
+
173
+ ## 0.5.2: complete Git collection before submitting a score
174
+
175
+ The complete HEAD object inventory now streams through the same bounded reader
176
+ as blame. Large old histories no longer have to fit into a 16 MiB output buffer
177
+ for that check. Missing objects remain errors. The 90-day window, 14-day settlement,
178
+ scoring calculations and archive limit are unchanged.
179
+
180
+ Unexpected Git command failures stop collection; they cannot become zero surviving
181
+ lines or an upload with durability omitted. A temporary OS `EAGAIN` failure to
182
+ start Git gets one automatic retry within the existing measurement deadline.
183
+ Timeouts, missing or corrupt history, and resource-limit failures do not trigger
184
+ unbounded retries. A technical failure exits with code 2 and sends no new score.
185
+
186
+ Tracked blob names are preserved exactly, including Git-quoted characters.
187
+ Submodule gitlinks are not source blobs and are not sent to blame; ordinary files,
188
+ symlinks and binary blobs retain their existing measurement treatment.
189
+
190
+ A valid report with zero settled or AI-attributed lines still proceeds. It does
191
+ not invent 100% AI survival. Missing coverage alone does not block submission.
192
+ A completed check below a configured maturity cutoff still exits with code 1;
193
+ track-only completion still exits with code 0.
package/bin/archive.mjs CHANGED
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"
2
2
  import { mkdtempSync, mkdirSync, readdirSync, lstatSync, existsSync, statSync, writeFileSync, rmSync } from "node:fs"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join, resolve, posix } from "node:path"
5
+ import { planComposerExclusions } from "./composer-vendor.mjs"
5
6
 
6
7
  export const ARCHIVE_LIMIT_BYTES = 60 * 1024 * 1024
7
8
  const MAX_DURABILITY_REPORT_BYTES = 1024 * 1024
@@ -23,8 +24,12 @@ export function normalizeExcludes(excludes = []) {
23
24
  })
24
25
  }
25
26
 
26
- function inventory(root, { reports = false, excludes = [] } = {}) {
27
+ function inventory(root, { reports = false, excludes = [], log = () => {} } = {}) {
27
28
  const paths = [], largest = []
29
+ const composerExcluded = reports ? [] : planComposerExclusions(root, excludes, SKIP, log)
30
+ for (const candidate of composerExcluded) {
31
+ log(`ScaleQuality: excluding installed Composer dependencies at ${JSON.stringify(candidate.path)}; retaining ${JSON.stringify(candidate.manifest)} and ${JSON.stringify(candidate.lockfile)} for dependency analysis. Git durability scope is unchanged.`)
32
+ }
28
33
  const walk = (rel) => {
29
34
  const abs = join(root, rel)
30
35
  const st = lstatSync(abs)
@@ -36,12 +41,12 @@ function inventory(root, { reports = false, excludes = [] } = {}) {
36
41
  }
37
42
  }
38
43
  const visit = (rel, name) => {
39
- if (!reports && (name === ".git" || rel === ".sqreports" || excludes.some((p) => rel === p || rel.startsWith(`${p}/`)))) return
44
+ if (!reports && (name === ".git" || rel === ".sqreports" || [...excludes, ...composerExcluded.map(item => item.path)].some((p) => rel === p || rel.startsWith(`${p}/`)))) return
40
45
  if (!reports && SKIP.has(name) && lstatSync(join(root, rel)).isDirectory()) return
41
46
  walk(rel)
42
47
  }
43
48
  for (const entry of readdirSync(root).sort()) visit(entry, entry)
44
- return { paths, largest: largest.sort((a, b) => b.bytes - a.bytes).slice(0, 5) }
49
+ return { paths, largest: largest.sort((a, b) => b.bytes - a.bytes).slice(0, 5), composerExcluded }
45
50
  }
46
51
 
47
52
  /**
@@ -58,7 +63,7 @@ export function prepareCheckoutArchive({ root, coverageRoot, durabilityReport, l
58
63
  process.once("exit", onExit)
59
64
  const cleanup = () => { process.removeListener("exit", onExit); rmSync(temp, { recursive: true, force: true }) }
60
65
  try {
61
- const source = inventory(root, { excludes })
66
+ const source = inventory(root, { excludes, log })
62
67
  const sourceList = join(temp, "source.list")
63
68
  writeFileSync(sourceList, source.paths.length ? `${source.paths.join("\0")}\0` : "")
64
69
  const metadataRoot = join(temp, "metadata")
@@ -66,6 +71,7 @@ export function prepareCheckoutArchive({ root, coverageRoot, durabilityReport, l
66
71
  const hasGit = existsSync(join(root, ".git"))
67
72
  const history = { mode: hasGit ? "omitted" : "unavailable", reason: durabilityReport ? "ci_report" : hasGit ? "invalid_git" : "missing_git" }
68
73
  writeFileSync(join(metadataRoot, ".sqreports/checkout.json"), JSON.stringify({ version: 1, history, archiveLimitBytes: ARCHIVE_LIMIT_BYTES }))
74
+ if (source.composerExcluded.length) writeFileSync(join(metadataRoot, ".sqreports/source.json"), JSON.stringify({ version: 1, composerDependencyExclusions: source.composerExcluded }))
69
75
  if (durabilityReport) {
70
76
  const json = JSON.stringify(durabilityReport)
71
77
  if (Buffer.byteLength(json) > MAX_DURABILITY_REPORT_BYTES) throw new Error("Durability report exceeds the 1 MiB report limit. No upload was sent.")
package/bin/check.mjs CHANGED
@@ -216,6 +216,7 @@ async function main() {
216
216
  const auth = { Authorization: `Bearer ${token}` }
217
217
  const excludes = normalizeExcludes(args.flatMap((arg, i) => arg === "--exclude" ? [args[i + 1]] : []))
218
218
  const durabilityReport = await measureDurability({ root: process.cwd(), log: jsonOut ? (message) => console.error(message) : log })
219
+ 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.")
219
220
  const coverage = stageCoverageReports()
220
221
  let packed, buf
221
222
  try {
@@ -298,6 +299,7 @@ async function main() {
298
299
  const cut = last.cutoff != null ? ` (cutoff ${last.cutoff})` : " (track only)"
299
300
  log(`ScaleQuality: maturity ${last.maturity ?? "—"}${cut}`)
300
301
  if (last.passed === false) console.error("ScaleQuality: below the line — failing the build.")
302
+ else if (last.cutoff == null) log("ScaleQuality: measurement completed (track only; no cutoff configured).")
301
303
  else log("ScaleQuality: passed.")
302
304
  }
303
305
  process.exit(last.passed === false ? 1 : 0)
@@ -0,0 +1,198 @@
1
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, readdirSync } from 'node:fs'
2
+ import { join, posix } from 'node:path'
3
+
4
+ const MAX_JSON_BYTES = 4 * 1024 * 1024
5
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
6
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) || a === '.' || b === '.'
7
+
8
+ // Inspect data only. Composer plugins/scripts and customer symlinks are never run/followed.
9
+ function readJson(path) {
10
+ let fd
11
+ try {
12
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
13
+ const stat = fstatSync(fd)
14
+ if (!stat.isFile() || stat.size > MAX_JSON_BYTES) return null
15
+ const raw = Buffer.alloc(stat.size + 1)
16
+ let bytes = 0
17
+ while (bytes < raw.length) {
18
+ const count = readSync(fd, raw, bytes, raw.length - bytes, null)
19
+ if (!count) break
20
+ bytes += count
21
+ }
22
+ if (bytes !== stat.size) return null
23
+ const value = JSON.parse(raw.subarray(0, bytes).toString('utf8'))
24
+ return object(value) ? value : null
25
+ } catch { return null }
26
+ finally { if (fd !== undefined) closeSync(fd) }
27
+ }
28
+
29
+ function safeRelative(value) {
30
+ if (typeof value !== 'string' || !value || /[\\\0*?\[\]$~]/.test(value) || value.startsWith('/') || /^[A-Za-z]:/.test(value)) return null
31
+ if (value.split('/').includes('..')) return null
32
+ const path = posix.normalize(value).replace(/\/$/, '')
33
+ return path === '.' || path.split('/').some(part => ['.git', '.sqreports'].includes(part)) ? null : path
34
+ }
35
+
36
+ function sourcePath(module, value) {
37
+ if (typeof value !== 'string' || /[\\\0$~]/.test(value) || value.startsWith('/') || /^[A-Za-z]:/.test(value)) return null
38
+ // A glob's fixed directory prefix bounds every possible source it might select.
39
+ const glob = value.search(/[*?\[]/)
40
+ const prefix = glob < 0 ? value : value.slice(0, glob).replace(/[^/]*$/, '')
41
+ const path = posix.normalize(posix.join(module || '.', prefix || '.')).replace(/\/$/, '')
42
+ return path === '..' || path.startsWith('../') ? null : path
43
+ }
44
+
45
+ function autoloadPaths(value) {
46
+ if (value === undefined) return []
47
+ if (!object(value)) return [null]
48
+ const paths = []
49
+ for (const key of ['psr-4', 'psr-0', 'classmap', 'files']) {
50
+ const entry = value[key]
51
+ if (entry === undefined) continue
52
+ const values = ['psr-4', 'psr-0'].includes(key) ? object(entry) ? Object.values(entry) : [null] : Array.isArray(entry) ? entry : [null]
53
+ for (const item of values) paths.push(...(Array.isArray(item) ? item : [item]))
54
+ }
55
+ return paths
56
+ }
57
+
58
+ function regularDirectory(root, path, consume = () => {}) {
59
+ let current = root
60
+ for (const part of path.split('/')) {
61
+ consume()
62
+ current = join(current, part)
63
+ try { if (!lstatSync(current).isDirectory()) return false }
64
+ catch { return false }
65
+ }
66
+ return true
67
+ }
68
+
69
+ /** A transport exclusion, never a change to the native Git durability scope. */
70
+ export function composerVendorDirectory(root, module = '', excludes = [], ignoredDirectories = new Set(), consume = () => {}) {
71
+ const manifest = readJson(join(root, module, 'composer.json'))
72
+ if (!manifest) return null
73
+ const label = posix.join(module, 'composer.json')
74
+ const keep = reason => ({ kept: true, manifest: label, reason })
75
+ const lock = readJson(join(root, module, 'composer.lock'))
76
+ if (!lock || !Array.isArray(lock.packages) || (lock['packages-dev'] !== undefined && !Array.isArray(lock['packages-dev']))) return keep('a valid composer.lock is required')
77
+ const packages = [...lock.packages, ...(lock['packages-dev'] || [])]
78
+ if (!packages.length || packages.some(pkg => !object(pkg) || typeof pkg.name !== 'string' || !pkg.name || typeof pkg.version !== 'string' || !pkg.version)) return keep('locked dependency versions could not be confirmed')
79
+ if (manifest.config !== undefined && !object(manifest.config)) return keep('Composer config is ambiguous')
80
+ const vendor = safeRelative(manifest.config && Object.hasOwn(manifest.config, 'vendor-dir') ? manifest.config['vendor-dir'] : 'vendor')
81
+ if (!vendor) return keep('vendor-dir must be a literal directory within the Composer project')
82
+ const path = posix.join(module, vendor)
83
+ // If either evidence file is explicitly excluded, do not infer a safe replacement for installed dependencies.
84
+ if (['composer.json', 'composer.lock'].some(file => excludes.some(ex => overlaps(ex, posix.join(module, file))))) return keep('the manifest or lockfile was explicitly excluded')
85
+
86
+ const repositories = manifest.repositories === undefined ? [] : Array.isArray(manifest.repositories) ? manifest.repositories : object(manifest.repositories) ? Object.values(manifest.repositories) : [null]
87
+ if (repositories.some(repo => repo !== false && !object(repo))) return keep('Composer repositories are ambiguous')
88
+ const localOrigins = [
89
+ ...repositories.filter(repo => repo && repo.type === 'path').map(repo => repo.url),
90
+ ...packages.filter(pkg => pkg.dist?.type === 'path').map(pkg => pkg.dist.url),
91
+ ]
92
+ // Resolving Composer's glob rules and each symlink target is outside this transport hotfix.
93
+ if (localOrigins.some(value => typeof value !== 'string' || /[*?\[]/.test(value))) return keep('local package origins use ambiguous paths or globs')
94
+ const sources = [...autoloadPaths(manifest.autoload), ...autoloadPaths(manifest['autoload-dev']), ...localOrigins]
95
+ if (sources.some(value => { consume(); const source = sourcePath(module, value); return source === null || overlaps(path, source) })) return keep('local source paths may overlap installed dependencies or lie outside the checkout')
96
+ if (localOrigins.some(value => {
97
+ const source = sourcePath(module, value)
98
+ consume()
99
+ return !regularDirectory(root, source, consume) || source.split('/').some(part => ignoredDirectories.has(part) || part === 'vendor') || excludes.some(ex => overlaps(ex, source))
100
+ })) return keep('a local package origin is missing, linked or excluded from the archive')
101
+ // A custom vendor-dir can have several components: reject a symlink in ANY component.
102
+ if (!regularDirectory(root, path, consume)) return null
103
+ return { path, manifest: label, lockfile: posix.join(module, 'composer.lock') }
104
+ }
105
+
106
+ function declaredSources(root, module, consume) {
107
+ const manifest = readJson(join(root, module, 'composer.json'))
108
+ if (!manifest) return [null]
109
+ const repositories = manifest.repositories === undefined ? [] : Array.isArray(manifest.repositories) ? manifest.repositories : object(manifest.repositories) ? Object.values(manifest.repositories) : [null]
110
+ if (repositories.some(repo => repo !== false && !object(repo))) return [null]
111
+ const lock = readJson(join(root, module, 'composer.lock'))
112
+ let packages = []
113
+ try {
114
+ lstatSync(join(root, module, 'composer.lock'))
115
+ if (!lock || !Array.isArray(lock.packages) || (lock['packages-dev'] !== undefined && !Array.isArray(lock['packages-dev']))) return [null]
116
+ packages = [...lock.packages, ...(lock['packages-dev'] || [])]
117
+ if (packages.some(pkg => !object(pkg))) return [null]
118
+ } catch (error) { if (error.code !== 'ENOENT') return [null] }
119
+ return [
120
+ ...autoloadPaths(manifest.autoload), ...autoloadPaths(manifest['autoload-dev']),
121
+ ...repositories.filter(repo => repo && repo.type === 'path').map(repo => repo.url),
122
+ ...packages.filter(pkg => pkg.dist?.type === 'path').map(pkg => pkg.dist.url),
123
+ ].map(value => {
124
+ consume()
125
+ // Resolving a glob or symlink could reveal another project's only source copy.
126
+ if (typeof value !== 'string' || /[*?\[]/.test(value)) return null
127
+ const path = sourcePath(module, value)
128
+ if (path === null) return null
129
+ let current = root
130
+ for (const part of path.split('/')) {
131
+ consume()
132
+ current = join(current, part)
133
+ try { if (lstatSync(current).isSymbolicLink()) return null }
134
+ catch (error) { if (error.code === 'ENOENT') break; return null }
135
+ }
136
+ return path
137
+ })
138
+ }
139
+
140
+ /** Discover all projects before applying any Composer exclusion. References can cross modules. */
141
+ export function planComposerExclusions(root, excludes = [], ignoredDirectories = new Set(), log = () => {}) {
142
+ const limit = new Error('Composer discovery resource limit')
143
+ const deadline = Date.now() + 5_000
144
+ let work = 0
145
+ const consume = () => { if (++work > 1_000_000 || Date.now() > deadline) throw limit }
146
+ try {
147
+ const projects = []
148
+ const pending = ['']
149
+ let visited = 0
150
+ while (pending.length) {
151
+ consume()
152
+ const module = pending.pop()
153
+ const entries = readdirSync(join(root, module), { withFileTypes: true })
154
+ visited += entries.length
155
+ if (visited > 100_000 || projects.length > 5_000) throw limit
156
+ if (entries.some(entry => entry.name === 'composer.json')) projects.push({
157
+ module,
158
+ candidate: composerVendorDirectory(root, module, excludes, ignoredDirectories, consume),
159
+ sources: declaredSources(root, module, consume),
160
+ })
161
+ for (const entry of entries.sort((a, b) => b.name.localeCompare(a.name))) {
162
+ consume()
163
+ const path = posix.join(module, entry.name)
164
+ if (!entry.isDirectory() || entry.name === '.git' || path === '.sqreports' || ignoredDirectories.has(entry.name) || excludes.some(ex => path === ex || path.startsWith(`${ex}/`))) continue
165
+ pending.push(path)
166
+ }
167
+ }
168
+ const candidates = projects.flatMap(project => project.candidate?.path ? [project.candidate] : [])
169
+ const protectedPaths = new Set()
170
+ const isActive = project => !candidates.some(candidate => { consume(); return !protectedPaths.has(candidate.path) && (project.module === candidate.path || project.module.startsWith(`${candidate.path}/`)) })
171
+ // Dependency package manifests do not veto their containing vendor directory.
172
+ // Once an external project references it, it is retained and its declarations
173
+ // become relevant too. Iterate until no newly retained project protects another.
174
+ let changed
175
+ do {
176
+ changed = false
177
+ const sources = projects.filter(isActive).flatMap(project => project.sources)
178
+ for (const candidate of candidates) {
179
+ if (!protectedPaths.has(candidate.path) && sources.some(source => { consume(); return source === null || overlaps(candidate.path, source) })) {
180
+ protectedPaths.add(candidate.path)
181
+ changed = true
182
+ }
183
+ }
184
+ } while (changed)
185
+ for (const project of projects.filter(isActive)) {
186
+ if (project.candidate?.kept) log(`ScaleQuality: retaining possible Composer dependencies near ${JSON.stringify(project.candidate.manifest)}: ${project.candidate.reason}.`)
187
+ }
188
+ for (const candidate of candidates) {
189
+ if (protectedPaths.has(candidate.path)) log(`ScaleQuality: retaining ${JSON.stringify(candidate.path)}: a project declares source there or its source paths could not be resolved safely.`)
190
+ }
191
+ // Nested installed package manifests cannot create independent upload exclusions.
192
+ return projects.filter(isActive).flatMap(project => project.candidate?.path && !protectedPaths.has(project.candidate.path) ? [project.candidate] : [])
193
+ } catch (error) {
194
+ if (error !== limit) throw error
195
+ log('ScaleQuality: retaining Composer dependency directories: project discovery exceeded its resource limit.')
196
+ return []
197
+ }
198
+ }
@@ -1,7 +1,27 @@
1
- import { runGit as git, isFatalGitError } from "./runtime.mjs";
1
+ import { runGit as git, runGitLines, GitMeasurementError } from "./runtime.mjs";
2
2
  const US = "\x1f"; // unit separator (field)
3
3
  const RS = "\x1e"; // record separator (commit)
4
4
  const CO_AUTHOR_RE = /co-authored-by:\s*(.*?)\s*<([^>]+)>/gi;
5
+ /** `git log --numstat` C-quotes path bytes, independently of the UTF-8 source.
6
+ * Decode only this transport representation; leave rename/statistics semantics
7
+ * unchanged. `ls-tree -z` and blame both use the resulting literal pathname. */
8
+ function decodeGitPath(path) {
9
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
10
+ const parts = [];
11
+ let literal = '';
12
+ const escapes = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '\\': 92, '"': 34 };
13
+ for (let i = 1; i < path.length - 1; i++) {
14
+ if (path[i] !== '\\') { literal += path[i]; continue; }
15
+ parts.push(Buffer.from(literal)); literal = '';
16
+ const escaped = path[++i];
17
+ if (Object.hasOwn(escapes, escaped)) parts.push(Buffer.from([escapes[escaped]]));
18
+ else if (/^[0-7]{3}$/.test(path.slice(i, i + 3))) {
19
+ parts.push(Buffer.from([parseInt(path.slice(i, i + 3), 8)])); i += 2;
20
+ } else throw new GitMeasurementError('Git file path could not be parsed');
21
+ }
22
+ parts.push(Buffer.from(literal));
23
+ return Buffer.concat(parts).toString('utf8');
24
+ }
5
25
  function parseCoAuthors(body) {
6
26
  const out = [];
7
27
  let m;
@@ -77,42 +97,48 @@ export async function readCommits(repoPath, sinceDays, untilDays = 0) {
77
97
  const del = Number.parseInt(cols[1], 10);
78
98
  rec.additions += Number.isFinite(add) ? add : 0;
79
99
  rec.deletions += Number.isFinite(del) ? del : 0;
80
- rec.files.push(cols.slice(2).join("\t"));
100
+ rec.files.push(decodeGitPath(cols.slice(2).join("\t")));
81
101
  }
82
102
  }
83
103
  return [...bySha.values()];
84
104
  }
85
- /** Files that currently exist at HEAD (tracked). */
105
+ /** Blameable entries currently at HEAD. Gitlinks point to another repository;
106
+ * native blame already contributed zero for them. Keep all blobs, including
107
+ * symlinks and binaries, without changing the commit/additions denominator. */
86
108
  export async function listHeadFiles(repoPath) {
87
- const out = await git(repoPath, ["ls-tree", "-r", "--name-only", "HEAD"]);
88
- return new Set(out.split("\n").map((l) => l.trim()).filter(Boolean));
109
+ const out = await git(repoPath, ["ls-tree", "-r", "-z", "HEAD"]);
110
+ const files = new Set();
111
+ for (const line of out.split("\0")) {
112
+ if (!line) continue;
113
+ const entry = /^[0-7]{6} (blob|tree|commit) [0-9a-f]{40}\t([\s\S]+)$/.exec(line);
114
+ if (!entry) throw new GitMeasurementError('Git tree output could not be parsed');
115
+ if (entry[1] === 'blob') files.add(entry[2]);
116
+ }
117
+ return files;
89
118
  }
90
- const SHA_LINE = /^([0-9a-f]{40}) /;
119
+ const BLAME_GROUP = /^([0-9a-f]{40}) [1-9][0-9]* [1-9][0-9]* ([1-9][0-9]*)$/;
91
120
  /**
92
121
  * Blames a file at HEAD and returns, per introducing commit sha, how many
93
- * CURRENT lines it owns (--line-porcelain → one sha header per line). Used
94
- * to compute "lines introduced in window that still survive". Returns an
95
- * empty map on any blame failure (deleted/binary/huge file).
122
+ * CURRENT lines it owns. Incremental output describes identical attribution
123
+ * in groups, without source lines or repeated per-line commit metadata.
124
+ * Stream those groups so large output does not require a large buffer.
125
+ * Callers select files present at HEAD. A failed command is incomplete evidence,
126
+ * never proof that this file has no surviving lines.
96
127
  */
97
128
  export async function blameFile(repoPath, file) {
98
129
  const counts = new Map();
99
- try {
100
- const out = await git(repoPath, [
101
- "blame",
102
- "--line-porcelain",
103
- "HEAD",
104
- "--",
105
- file,
106
- ]);
107
- for (const line of out.split("\n")) {
108
- const m = SHA_LINE.exec(line);
109
- if (m)
110
- counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
111
- }
112
- }
113
- catch (error) {
114
- if (isFatalGitError(error)) throw error;
115
- // deleted at HEAD / binary / unreadable → no surviving lines counted
116
- }
130
+ await runGitLines(repoPath, [
131
+ "blame",
132
+ "--incremental",
133
+ "HEAD",
134
+ "--",
135
+ file,
136
+ ], line => {
137
+ const m = BLAME_GROUP.exec(line);
138
+ if (!m) return;
139
+ const total = (counts.get(m[1]) ?? 0) + Number(m[2]);
140
+ if (!Number.isSafeInteger(total)) throw new GitMeasurementError('Git blame line count exceeded the numeric limit');
141
+ counts.set(m[1], total);
142
+ });
117
143
  return counts;
118
144
  }
@@ -12,9 +12,18 @@ moduleResolution node, strict true, esModuleInterop true, skipLibCheck true, and
12
12
  Node type declarations. No dependencies are needed by the resulting runtime.
13
13
  Rename the generated files to `.mjs` and give their relative imports `.mjs`
14
14
  extensions. The engine's calculations and classifier remain unchanged. In the
15
- reader, replace its promisified `execFile` wrapper with `runtime.mjs`'s `runGit`,
16
- and rethrow `isFatalGitError(error)` in its per-file blame catch. Retain native
17
- handling for other per-file errors. Recompute provenance hashes and run the
15
+ reader, replace its promisified `execFile` wrapper with `runtime.mjs`'s `runGit`.
16
+ For blame, use `runGitLines` with `--incremental` and sum each group's line count
17
+ instead of counting individual `--line-porcelain` headers. Attribution is unchanged;
18
+ source text and repeated per-line metadata are not emitted or retained. Any failed
19
+ blame aborts the report instead of returning empty or partial counts. Read the
20
+ object type from `ls-tree -rz` and select only blobs, preserving literal names
21
+ without trimming. Decode Git's C-quoted path bytes in `log --numstat` so tabs,
22
+ quotes, Unicode and leading/trailing whitespace match these same blob names.
23
+ This skips only submodule gitlinks,
24
+ whose native blame contributed zero; binary and symlink blobs remain eligible.
25
+ Commit statistics and the introduced-lines denominator remain unchanged.
26
+ Recompute provenance hashes and run the
18
27
  parity and cancellation tests. The reference modules under
19
28
  `test/native-durability-reference/` preserve the original reader for small fixture
20
29
  comparisons only; they are excluded from the published package.
@@ -27,10 +36,16 @@ part of native Git measurement even if archive exclusions omit those directories
27
36
  No clone, fetch, checkout or Git write is performed. Shallow or partial histories,
28
37
  missing objects, changing HEAD and invalid reports produce no measurement.
29
38
  The separate worker has a 120-second total budget and 512 MiB V8 heap ceiling;
30
- each Git command has at most 30 seconds and 16 MiB of output. The complete HEAD
31
- object inventory uses this same limit, so sufficiently large object inventories
32
- can be unavailable even when the 90-day change window is small. Limit failures
33
- are reported as unavailable, never converted into successful zero measurements.
39
+ each Git command has at most 30 seconds. Buffered commands retain at most 16 MiB
40
+ of output. Incremental blame and the complete HEAD object inventory stream stdout,
41
+ retaining one line (at most 16 MiB) and at most 16 MiB of stderr, rather than
42
+ limiting total streamed output. One failed OS spawn with `EAGAIN` can recover after
43
+ 100 ms, within the original total deadline; the recovery allowance is shared by
44
+ all Git commands. Command exits, signals, timeouts, missing/incomplete history and
45
+ deterministic resource failures are never automatically retried.
46
+ Collection failures abort the CLI before packaging or upload and cannot submit a
47
+ replacement score. A validated report with zero settled or AI-eligible lines is
48
+ still a successful collection; an absent optional coverage report is permitted.
34
49
  Only the measurement worker's own process group and Git children are cleaned up
35
50
  on cancellation or parent IPC disconnection. Git command output, author details,
36
51
  credentials and customer environment tokens are not logged or sent to the worker.
@@ -29,7 +29,11 @@
29
29
  "runtimeChanges": [
30
30
  "ESM relative import extensions only in engine/classifier",
31
31
  "GitReader process wrapper replaced by bounded local-only runner",
32
- "GitReader blame catch rethrows resource/incomplete-history failures; ordinary per-file errors retain native handling"
32
+ "GitReader blame aborts the report on any failed command; partial counts never become a successful measurement",
33
+ "GitReader selects literal blob paths from NUL-delimited ls-tree and decodes C-quoted numstat filenames; submodule gitlinks retain zero native contribution without changing introduced-line counts",
34
+ "GitReader blame streams incremental groups and sums their line counts with exact native attribution; bounded lines and stderr preserve resource limits without capping total streamed stdout",
35
+ "Worker streams the complete HEAD object inventory and rejects missing objects without buffering total output",
36
+ "Runtime permits one recovery from OS spawn EAGAIN within the original deadline; all other collection failures abort the CLI before upload"
33
37
  ],
34
38
  "generatedFiles": [
35
39
  {
@@ -38,7 +42,7 @@
38
42
  },
39
43
  {
40
44
  "file": "GitReader.mjs",
41
- "sha256": "447ce620849adc44e3abdf153174ce485ef5391cb29a00fc2e1d9a3064197b96"
45
+ "sha256": "e86e2e8251f5d9e8f50f671805d8926346aeb574bd2d6a115e5cd324555858f3"
42
46
  },
43
47
  {
44
48
  "file": "OriginClassifier.mjs",
@@ -1,15 +1,17 @@
1
- import { execFile } from 'node:child_process'
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { setTimeout as delay } from 'node:timers/promises'
2
3
 
3
4
  export const MAX_GIT_BUFFER = 16 * 1024 * 1024
4
5
  export const MAX_GIT_TIME_MS = 30_000
5
6
  let deadline = 0
7
+ let spawnRecoveryUsed = false
6
8
  const children = new Set()
7
9
 
8
10
  export class GitMeasurementError extends Error {
9
11
  constructor(reason) { super(reason); this.reason = reason; this.fatalMeasurement = true }
10
12
  }
11
13
  export const isFatalGitError = (error) => error?.fatalMeasurement === true
12
- export function setDeadline(value) { deadline = value }
14
+ export function setDeadline(value) { deadline = value; spawnRecoveryUsed = false }
13
15
  export function remainingTime() {
14
16
  const remaining = deadline - Date.now()
15
17
  if (remaining <= 0) throw new GitMeasurementError('time budget exceeded')
@@ -27,29 +29,120 @@ export function localGitEnvironment() {
27
29
  return { ...env, LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_NO_LAZY_FETCH: '1' }
28
30
  }
29
31
 
30
- export async function runGit(repoPath, args, { allowMissingConfig = false } = {}) {
32
+ function gitArgs(repoPath, args) {
33
+ return [
34
+ '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'),
35
+ '-c', 'credential.helper=', '-c', 'protocol.allow=never', '-c', 'protocol.file.allow=never',
36
+ '-C', repoPath, ...args,
37
+ ]
38
+ }
39
+
40
+ function gitError(error, stderr) {
41
+ // EAGAIN is a failed OS spawn, before Git ran or emitted any result. A child
42
+ // exit status, timeout or deterministic Git/resource error is not retryable.
43
+ if (error.code === 'EAGAIN') {
44
+ const failure = new GitMeasurementError('Git process could not start because system resources are temporarily unavailable')
45
+ failure.retryableSpawn = true
46
+ return failure
47
+ }
48
+ if (error.code === 'ENOENT') return new GitMeasurementError('Git is unavailable')
49
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS') return new GitMeasurementError('Git output exceeded the 16 MiB resource limit')
50
+ if (error.killed || error.signal || error.code === 'ETIMEDOUT') return new GitMeasurementError('Git command exceeded the time budget')
51
+ if (/missing (?:blob|tree|commit|object)|bad object|unable to read|could not read|object .*corrupt|object file .*empty|invalid object|promisor|transport .*not allowed/i.test(stderr || '')) {
52
+ return new GitMeasurementError('Git history is incomplete or unavailable locally')
53
+ }
54
+ // Never propagate command output, author identities or credentials to logs.
55
+ const safeError = new GitMeasurementError('Git command failed')
56
+ safeError.code = error.code
57
+ return safeError
58
+ }
59
+
60
+ async function withSpawnRecovery(operation) {
61
+ try { return await operation() } catch (error) {
62
+ if (!error?.retryableSpawn || spawnRecoveryUsed) throw error
63
+ spawnRecoveryUsed = true
64
+ remainingTime()
65
+ await delay(Math.min(100, remainingTime()))
66
+ remainingTime()
67
+ return operation()
68
+ }
69
+ }
70
+
71
+ export async function runGit(repoPath, args, options = {}) {
72
+ return withSpawnRecovery(() => runGitOnce(repoPath, args, options))
73
+ }
74
+
75
+ function runGitOnce(repoPath, args, { allowMissingConfig = false } = {}) {
31
76
  const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
32
77
  return new Promise((resolve, reject) => {
33
- const child = execFile('git', [
34
- '--no-pager', '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=' + (process.platform === 'win32' ? 'NUL' : '/dev/null'),
35
- '-c', 'credential.helper=', '-c', 'protocol.allow=never', '-c', 'protocol.file.allow=never',
36
- '-C', repoPath, ...args,
37
- ], { encoding: 'utf8', maxBuffer: MAX_GIT_BUFFER, timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment() }, (error, stdout, stderr) => {
78
+ const child = execFile('git', gitArgs(repoPath, args), { encoding: 'utf8', maxBuffer: MAX_GIT_BUFFER, timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment() }, (error, stdout, stderr) => {
38
79
  children.delete(child)
39
80
  if (!error) { try { remainingTime(); resolve(stdout) } catch (e) { reject(e) }; return }
40
81
  if (allowMissingConfig && error.code === 1) { resolve(''); return }
41
- if (error.code === 'ENOENT') { reject(new GitMeasurementError('Git is unavailable')); return }
42
- if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' || error.code === 'ENOBUFS') { reject(new GitMeasurementError('Git output exceeded the 16 MiB resource limit')); return }
43
- if (error.killed || error.signal || error.code === 'ETIMEDOUT') { reject(new GitMeasurementError('Git command exceeded the time budget')); return }
44
- if (/missing (?:blob|tree|commit|object)|bad object|unable to read|could not read|object .*corrupt|object file .*empty|invalid object|promisor|transport .*not allowed/i.test(stderr || '')) {
45
- reject(new GitMeasurementError('Git history is incomplete or unavailable locally')); return
46
- }
47
- // Preserve the native reader's handling of ordinary per-file blame errors.
48
- // Never propagate command output, author identities or credentials to logs.
49
- const safeError = new Error('Git command failed')
50
- safeError.code = error.code
51
- reject(safeError)
82
+ reject(gitError(error, stderr))
52
83
  })
53
84
  children.add(child)
54
85
  })
55
86
  }
87
+
88
+ /** Consume stdout a line at a time; retain at most one bounded line, not the full output. */
89
+ export async function runGitLines(repoPath, args, onLine) {
90
+ return withSpawnRecovery(() => runGitLinesOnce(repoPath, args, onLine))
91
+ }
92
+
93
+ function runGitLinesOnce(repoPath, args, onLine) {
94
+ const timeout = Math.min(MAX_GIT_TIME_MS, remainingTime())
95
+ return new Promise((resolve, reject) => {
96
+ let pending = ''
97
+ let stderr = ''
98
+ let failure
99
+ const child = spawn('git', gitArgs(repoPath, args), {
100
+ stdio: ['ignore', 'pipe', 'pipe'], timeout, killSignal: 'SIGKILL', windowsHide: true, env: localGitEnvironment(),
101
+ })
102
+ children.add(child)
103
+ const fail = error => {
104
+ failure ??= error
105
+ child.kill('SIGKILL')
106
+ }
107
+ const emit = line => {
108
+ if (Buffer.byteLength(line) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
109
+ onLine(line)
110
+ }
111
+ child.stdout.setEncoding('utf8')
112
+ child.stdout.on('data', chunk => {
113
+ if (failure) return
114
+ try {
115
+ remainingTime()
116
+ const text = pending + chunk
117
+ let start = 0
118
+ for (let end = text.indexOf('\n'); end !== -1; end = text.indexOf('\n', start)) {
119
+ emit(text.slice(start, end))
120
+ start = end + 1
121
+ }
122
+ pending = text.slice(start)
123
+ if (Buffer.byteLength(pending) > MAX_GIT_BUFFER) throw gitError({ code: 'ENOBUFS' })
124
+ } catch (error) {
125
+ fail(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
126
+ }
127
+ })
128
+ child.stderr.setEncoding('utf8')
129
+ child.stderr.on('data', chunk => {
130
+ if (failure) return
131
+ if (Buffer.byteLength(stderr) + Buffer.byteLength(chunk) > MAX_GIT_BUFFER) fail(gitError({ code: 'ENOBUFS' }))
132
+ else stderr += chunk
133
+ })
134
+ child.on('error', error => { failure ??= gitError(error, stderr) })
135
+ child.on('close', (code, signal) => {
136
+ children.delete(child)
137
+ if (failure) { reject(failure); return }
138
+ if (code !== 0 || signal) { reject(gitError({ code, signal, killed: child.killed }, stderr)); return }
139
+ try {
140
+ remainingTime()
141
+ if (pending) emit(pending)
142
+ resolve()
143
+ } catch (error) {
144
+ reject(isFatalGitError(error) ? error : new GitMeasurementError('Git output could not be parsed'))
145
+ }
146
+ })
147
+ })
148
+ }
@@ -1,7 +1,7 @@
1
1
  import { realpath } from 'node:fs/promises'
2
2
  import { analyzeDurability } from './GitDurabilityEngine.mjs'
3
3
  import { ENGINE_COMMIT, validateReport } from './validate.mjs'
4
- import { runGit, setDeadline, remainingTime, stopGitChildren, GitMeasurementError } from './runtime.mjs'
4
+ import { runGit, runGitLines, setDeadline, remainingTime, stopGitChildren, GitMeasurementError } from './runtime.mjs'
5
5
 
6
6
  process.on('SIGTERM', () => { stopGitChildren(); process.exit(2) })
7
7
  let completed = false
@@ -20,8 +20,9 @@ process.once('message', async ({ root, deadline }) => {
20
20
  if (partial.split('\n').some(line => /^extensions\.partialclone\s+\S/i.test(line) || /^remote\..*\.promisor\s+(true|yes|on|1)\s*$/i.test(line))) throw new GitMeasurementError('partial Git history cannot be measured without fetching objects')
21
21
  const headSha = (await runGit(root, ['rev-parse', '--verify', 'HEAD'])).trim()
22
22
  if (!/^[0-9a-f]{40}$/.test(headSha)) throw new GitMeasurementError('a valid SHA-1 Git HEAD is required')
23
- const objects = await runGit(root, ['rev-list', '--objects', '--no-object-names', '--missing=print', 'HEAD'])
24
- if (objects.split('\n').some(line => /^\?[0-9a-f]+$/.test(line))) throw new GitMeasurementError('Git history is missing local objects')
23
+ await runGitLines(root, ['rev-list', '--objects', '--no-object-names', '--missing=print', 'HEAD'], line => {
24
+ if (/^\?[0-9a-f]+$/.test(line)) throw new GitMeasurementError('Git history is missing local objects')
25
+ })
25
26
  const measuredAt = new Date().toISOString()
26
27
  const result = await analyzeDurability(root, {
27
28
  windowDays: 90, settleDays: 14,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalequality/check",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": {