code-foundry 1.10.1 → 1.11.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.
@@ -23,7 +23,11 @@ unit_runner: ubuntu-slim
23
23
  performance_runner: ubuntu-latest
24
24
  cache_packages: auto
25
25
  cache_build: auto
26
+ required_capabilities:
27
+ coverage_enforcement: auto
26
28
  coverage_minimum: 80
29
+ coverage_metrics: lines
30
+ coverage_report:
27
31
  turbo_remote: auto
28
32
  ci_runner: ubuntu-latest
29
33
  test_runner: ubuntu-latest
@@ -63,6 +63,7 @@ jobs:
63
63
  sparse-checkout: |
64
64
  .github/actions
65
65
  src/lib
66
+ src/runtime-core.mjs
66
67
  src/runtime.mjs
67
68
  - name: Install runtime
68
69
  run: |
@@ -107,6 +108,7 @@ jobs:
107
108
  sparse-checkout: |
108
109
  .github/actions
109
110
  src/lib
111
+ src/runtime-core.mjs
110
112
  src/runtime.mjs
111
113
  - name: Install runtime
112
114
  run: |
@@ -146,6 +148,7 @@ jobs:
146
148
  sparse-checkout: |
147
149
  .github/actions
148
150
  src/lib
151
+ src/runtime-core.mjs
149
152
  src/runtime.mjs
150
153
  - name: Install runtime
151
154
  run: |
@@ -190,6 +193,7 @@ jobs:
190
193
  sparse-checkout: |
191
194
  .github/actions
192
195
  src/lib
196
+ src/runtime-core.mjs
193
197
  src/runtime.mjs
194
198
  - name: Install runtime
195
199
  run: |
@@ -118,6 +118,7 @@ jobs:
118
118
  sparse-checkout: |
119
119
  .github/actions
120
120
  src/lib
121
+ src/runtime-core.mjs
121
122
  src/runtime.mjs
122
123
  - name: Install runtime
123
124
  run: |
@@ -210,6 +211,7 @@ jobs:
210
211
  sparse-checkout: |
211
212
  .github/actions
212
213
  src/lib
214
+ src/runtime-core.mjs
213
215
  src/runtime.mjs
214
216
  - name: Install runtime
215
217
  if: ${{ matrix.entry.changed == true }}
@@ -88,6 +88,7 @@ jobs:
88
88
  sparse-checkout: |
89
89
  .github/actions
90
90
  src/lib
91
+ src/runtime-core.mjs
91
92
  src/runtime.mjs
92
93
  - name: Install runtime
93
94
  run: |
@@ -210,6 +211,7 @@ jobs:
210
211
  sparse-checkout: |
211
212
  .github/actions
212
213
  src/lib
214
+ src/runtime-core.mjs
213
215
  src/runtime.mjs
214
216
  - name: Install runtime
215
217
  run: |
@@ -78,6 +78,7 @@ jobs:
78
78
  sparse-checkout: |
79
79
  .github/actions
80
80
  src/lib
81
+ src/runtime-core.mjs
81
82
  src/runtime.mjs
82
83
  - name: Install runtime
83
84
  run: |
@@ -143,6 +144,7 @@ jobs:
143
144
  sparse-checkout: |
144
145
  .github/actions
145
146
  src/lib
147
+ src/runtime-core.mjs
146
148
  src/runtime.mjs
147
149
  - name: Install runtime
148
150
  run: |
@@ -208,6 +210,7 @@ jobs:
208
210
  sparse-checkout: |
209
211
  .github/actions
210
212
  src/lib
213
+ src/runtime-core.mjs
211
214
  src/runtime.mjs
212
215
  - name: Install runtime
213
216
  run: |
@@ -257,6 +260,7 @@ jobs:
257
260
  sparse-checkout: |
258
261
  .github/actions
259
262
  src/lib
263
+ src/runtime-core.mjs
260
264
  src/runtime.mjs
261
265
  - name: Install runtime
262
266
  run: |
@@ -318,6 +322,7 @@ jobs:
318
322
  sparse-checkout: |
319
323
  .github/actions
320
324
  src/lib
325
+ src/runtime-core.mjs
321
326
  src/runtime.mjs
322
327
  - name: Install runtime
323
328
  run: |
@@ -143,6 +143,7 @@ jobs:
143
143
  path: .github/.code-foundry
144
144
  sparse-checkout: |
145
145
  src/lib
146
+ src/runtime-core.mjs
146
147
  src/runtime.mjs
147
148
  - name: Install runtime
148
149
  run: mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
@@ -155,6 +155,7 @@ jobs:
155
155
  path: .github/.code-foundry
156
156
  sparse-checkout: |
157
157
  src/lib
158
+ src/runtime-core.mjs
158
159
  src/runtime.mjs
159
160
  - name: Install runtime
160
161
  run: mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
@@ -45,6 +45,7 @@ jobs:
45
45
  path: .github/.code-foundry
46
46
  sparse-checkout: |
47
47
  src/lib
48
+ src/runtime-core.mjs
48
49
  src/runtime.mjs
49
50
  - name: Install runtime
50
51
  run: mv .github/.code-foundry "$RUNNER_TEMP/code-foundry"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0](https://github.com/0xPlayerOne/code-foundry/compare/v1.10.1...v1.11.0) (2026-09-09)
4
+
5
+
6
+ ### Features
7
+
8
+ * **validation:** enforce required capabilities, coverage, and task evidence ([#534](https://github.com/0xPlayerOne/code-foundry/issues/534)) ([2e341d9](https://github.com/0xPlayerOne/code-foundry/commit/2e341d94381599f1d78ec1394d42883157b30ea6))
9
+
3
10
  ## [1.10.1](https://github.com/0xPlayerOne/code-foundry/compare/v1.10.0...v1.10.1) (2026-09-09)
4
11
 
5
12
 
@@ -36,10 +36,15 @@ repository manifests and source
36
36
  | `package_manager` | `bun`, `pnpm`, `yarn`, `npm`, `none` | JavaScript setup |
37
37
  | `toolchain` | `auto`, `native`, `mise` | Environment setup policy; defaults to `auto` |
38
38
  | `staging_validation_mode` | `fast`, `audit` | Staging-release-only validation tier; omitted from direct repositories |
39
- | `performance` | `auto`, `true`, `false` | Run a deterministic performance task when a supported entrypoint exists; defaults to `auto` |
39
+ | `performance` | `auto`, `true`, `false` | `auto` discovers an optional task; `true` requires a supported performance entrypoint; `false` disables discovery |
40
40
  | `performance_command` | JSON argv array or array of argv arrays | One or more ordered commands for non-package harnesses; package scripts take precedence |
41
41
  | `performance_profile` | empty, `node-package` | Optional shared package import, memory, archive, and dependency budget harness |
42
42
  | `performance_budget_file` | repository path | Budget policy for the shared package harness; defaults to `performance-package-budgets.json` |
43
+ | `required_capabilities` | comma-separated task names | Fail closed when a required task or `coverage` evidence is unavailable; defaults to none |
44
+ | `coverage_enforcement` | `auto`, `required`, `off` | Shared coverage report policy; `auto` accepts explicit skips, `required` rejects missing reports, `off` skips it |
45
+ | `coverage_minimum` | percentage from `0` to `100` | Minimum measured coverage; defaults to `80` |
46
+ | `coverage_metrics` | `lines`, `functions`, `branches`, `statements` | Metrics checked by the shared coverage gate; defaults to `lines` |
47
+ | `coverage_report` | comma-separated repository paths | Istanbul summary or LCOV evidence files; defaults to standard coverage paths |
43
48
  | `features` | `all` or a list | Standard workflow callers |
44
49
  | `codeql` | `auto`, `true`, `false` | CodeQL policy; public repositories default to enabled, non-public repositories default to disabled |
45
50
  | `codeql_rust_shards` | JSON array of paths | Rust scan scopes; `["all"]` keeps the safe single full scan |
package/docs/README.md CHANGED
@@ -13,6 +13,7 @@ its own names, environments, and deployment details.
13
13
  - [Publishing packages](PUBLISHING.md)
14
14
  - [Caching and remote caching](CACHING.md)
15
15
  - [Performance budgets and baselines](PERFORMANCE.md)
16
+ - [Required capabilities and task evidence](required-capabilities.md)
16
17
 
17
18
  ## Repository-specific documentation
18
19
 
@@ -0,0 +1,82 @@
1
+ # Required capabilities and task evidence
2
+
3
+ Declared requirements fail closed when discovery cannot find an executable task.
4
+ Existing optional task discovery remains available. Configure scalar values in
5
+ `.github/code-foundry.yml`:
6
+
7
+ ```yaml
8
+ required_capabilities: type_check,unit,e2e,performance,coverage
9
+ performance: true
10
+ coverage_enforcement: required
11
+ coverage_minimum: 80
12
+ coverage_metrics: lines,branches
13
+ coverage_report: coverage/coverage-summary.json
14
+ ```
15
+
16
+ Supported task capabilities are `format`, `lint`, `type_check`, `build`, `unit`,
17
+ `integration`, `e2e`, `smoke`, and `performance`. `coverage` additionally requires
18
+ unit tests. Unknown names, contradictory requirements, and invalid thresholds
19
+ are errors. `performance: true` now means required, not merely enabled when a
20
+ script happens to exist. Use `performance: auto` to retain optional discovery.
21
+
22
+ The public `src/runtime.mjs` entrypoint delegates ecosystem execution to the
23
+ unchanged private `src/runtime-core.mjs`. Keep both files and `src/lib` when
24
+ vendoring the runtime. Published packages and the reusable workflows' existing
25
+ cone-mode sparse checkout include both files. Do not call the private executor
26
+ from consumer CI: it intentionally does not enforce the public policy contract.
27
+
28
+ ## Coverage migration
29
+
30
+ `coverage_enforcement` accepts:
31
+
32
+ - `auto` (default): missing reports are explicitly reported as skipped; present
33
+ reports must be fresh, valid, and meet the threshold.
34
+ - `required`: missing reports also fail, as does required capability `coverage`.
35
+ - `off`: skip the shared report gate. This does not disable a test runner's own
36
+ coverage threshold, and cannot be combined with required coverage.
37
+
38
+ The default report candidates are `coverage/coverage-summary.json` (Istanbul
39
+ summary) and `coverage/lcov.info`. `coverage_report` accepts a comma-separated
40
+ list of repository-relative files. When multiple reports exist, every report
41
+ must meet the selected metrics. Percentages are recomputed from measured counts,
42
+ not trusted from reported `pct` fields. Empty reports, unmeasured selected
43
+ metrics, stale files, traversal, and escaping symlinks fail. LCOV supports lines,
44
+ functions, and branches; use JSON summaries for statement coverage.
45
+
46
+ Configure the repository-owned unit-test command to generate the report on each
47
+ run before enabling required mode. The runtime does not inject coverage tooling,
48
+ install a new runner, delete old reports, or lower thresholds. Auto mode preserves
49
+ repos that declare a threshold but have not yet configured report generation;
50
+ a passing unit task with `coverage.status: skipped` is **not** coverage evidence.
51
+
52
+ ## Results and validation tiers
53
+
54
+ Each executed CI task writes `.code-foundry/results/<task>.json`, including its
55
+ status (`passed`, `failed`, or `skipped`), discovery reason, delegated command and
56
+ exit status, source SHA when available, timestamps, and evidence paths. Coverage
57
+ has its own nested status. Discovery also records optional skips. Task reports
58
+ appear in the GitHub job summary when `GITHUB_STEP_SUMMARY` is available.
59
+
60
+ The recorded command is the delegated executor invocation, not a transcript of
61
+ all nested package scripts. No environment variables or captured command output
62
+ are copied into the report. Repository scripts remain responsible for sanitizing
63
+ their own logs. Upload `.code-foundry/results/*.json` with `if: always()` and
64
+ `include-hidden-files: true` to retain downloadable reports; only upload this
65
+ specific directory, not arbitrary hidden files or the entire checkout.
66
+
67
+ `node src/runtime.mjs ci plan` prints a JSON discovery plan without executing
68
+ checks. Discovery validates every required task before reusable workflows select
69
+ jobs. A fast/unit-only tier is still a subset: discovery proves that E2E exists,
70
+ not that E2E ran. Require the existing audit validation gate before merging.
71
+
72
+ Native task detection rejects known no-ops such as a JavaScript project with no
73
+ build script or a Python project with no supported type-check command. Add a
74
+ repository-owned script for unsupported layouts or toolchains. Do not infer that
75
+ an installed package manager or discovered project proves task execution.
76
+
77
+ ## Verification
78
+
79
+ `node --test test/task-policy.test.mjs` exercises policy parsing, discovery,
80
+ coverage parsing/thresholds/freshness/path safety, exit propagation, and reports
81
+ with a deterministic executor fixture. The unchanged ecosystem executor remains
82
+ covered by `test/runtime.test.mjs` in the full suite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-foundry",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "A fast, language-aware repository factory for agent-ready workflows, testing, security, and release automation.",
5
5
  "homepage": "https://github.com/0xPlayerOne/code-foundry#readme",
6
6
  "bugs": {
@@ -1288,7 +1288,11 @@ function createDefaultConfig(root, source, configuredWorkflow) {
1288
1288
  prune_standard: 'false',
1289
1289
  cache_packages: 'auto',
1290
1290
  cache_build: 'auto',
1291
+ required_capabilities: '',
1292
+ coverage_enforcement: 'auto',
1291
1293
  coverage_minimum: '80',
1294
+ coverage_metrics: 'lines',
1295
+ coverage_report: '',
1292
1296
  turbo_remote: 'auto',
1293
1297
  release_type: detectPackageManager(root) === 'none' ? 'auto' : 'node',
1294
1298
  npm_publish: 'false',
@@ -0,0 +1,290 @@
1
+ // @ts-check
2
+
3
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
4
+ import { spawnSync } from 'node:child_process'
5
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
6
+ import { createHash } from 'node:crypto'
7
+ import { listValue, readConfig } from './config.mjs'
8
+
9
+ export const TASKS = Object.freeze([
10
+ 'format',
11
+ 'lint',
12
+ 'type_check',
13
+ 'build',
14
+ 'unit',
15
+ 'integration',
16
+ 'e2e',
17
+ 'smoke',
18
+ 'performance',
19
+ ])
20
+
21
+ /** @type {Readonly<Record<string, readonly string[]>>} */
22
+ export const TASK_SCRIPTS = Object.freeze({
23
+ format: ['format:check', 'format', 'fmt'],
24
+ lint: ['lint'],
25
+ type_check: ['type-check', 'typecheck', 'type:check'],
26
+ build: ['build'],
27
+ unit: ['test:unit', 'test:coverage', 'test'],
28
+ integration: ['test:integration'],
29
+ e2e: ['test:e2e', 'e2e'],
30
+ smoke: ['test:smoke', 'smoke'],
31
+ performance: ['performance:check', 'perf:check'],
32
+ })
33
+
34
+ /** @param {string} root */
35
+ export function readTaskPolicy(root) {
36
+ const config = readConfig(resolve(root, '.github/code-foundry.yml'))
37
+ const required = listValue(config.required_capabilities ?? '')
38
+ for (const capability of required) {
39
+ if (![...TASKS, 'coverage'].includes(capability))
40
+ throw new Error(`Unknown required capability: ${capability}`)
41
+ }
42
+ if (!['true', 'false', 'auto'].includes(config.performance ?? 'auto'))
43
+ throw new Error('performance must be true, false, or auto')
44
+ const coverageMode = config.coverage_enforcement ?? 'auto'
45
+ if (!['auto', 'required', 'off'].includes(coverageMode))
46
+ throw new Error('coverage_enforcement must be auto, required, or off')
47
+ if (required.includes('coverage') && coverageMode === 'off')
48
+ throw new Error('Required coverage cannot use coverage_enforcement: off')
49
+ if (required.includes('performance') && config.performance === 'false')
50
+ throw new Error('Required performance cannot use performance: false')
51
+ if (config.performance === 'true' && !required.includes('performance'))
52
+ required.push('performance')
53
+ const coverageRequired = required.includes('coverage') || coverageMode === 'required'
54
+ if (coverageRequired && !required.includes('unit')) required.push('unit')
55
+ const minimum = Number(config.coverage_minimum ?? '80')
56
+ if (!Number.isFinite(minimum) || minimum < 0 || minimum > 100)
57
+ throw new Error('coverage_minimum must be a finite percentage between 0 and 100')
58
+ const metrics = listValue(config.coverage_metrics ?? 'lines')
59
+ if (
60
+ !metrics.length ||
61
+ metrics.some(
62
+ (metricName) => !['lines', 'functions', 'branches', 'statements'].includes(metricName)
63
+ )
64
+ )
65
+ throw new Error('coverage_metrics must select lines, functions, branches, or statements')
66
+ return { config, required, coverageMode, coverageRequired, minimum, metrics }
67
+ }
68
+
69
+ /** @param {string} root @returns {Record<string, any>} */
70
+ export function readTaskPackage(root) {
71
+ const file = resolve(root, 'package.json')
72
+ if (!existsSync(file)) return {}
73
+ const value = JSON.parse(readFileSync(file, 'utf8'))
74
+ if (!value || typeof value !== 'object' || Array.isArray(value))
75
+ throw new Error('package.json must contain an object')
76
+ return value
77
+ }
78
+
79
+ /**
80
+ * Explain the core's applicability decision and reject known native no-ops.
81
+ * Discovery is not execution; a native command may still fail at run time.
82
+ * @param {string} root
83
+ * @param {string} task
84
+ * @param {Record<string, string>} profile
85
+ */
86
+ export function describeTask(root, task, profile) {
87
+ if (!TASKS.includes(task)) throw new Error(`Unknown CI task: ${task}`)
88
+ const policy = readTaskPolicy(root)
89
+ const pkg = readTaskPackage(root)
90
+ const script = TASK_SCRIPTS[task]?.find((name) => {
91
+ const value = pkg.scripts?.[name]
92
+ return typeof value === 'string' && value.trim().length > 0
93
+ })
94
+ const required = policy.required.includes(task)
95
+ let applicable = profile.applicable === 'true'
96
+ let reason = applicable ? 'native runtime discovery' : 'no supported entrypoint was discovered'
97
+ if (script && applicable) reason = `package-script:${script}`
98
+ if (applicable && !script && ['format', 'lint', 'type_check', 'build'].includes(task)) {
99
+ const rust = profile.rust === 'true' && existsSync(resolve(root, 'Cargo.toml'))
100
+ const python =
101
+ profile.python === 'true' &&
102
+ (existsSync(resolve(root, 'pyproject.toml')) ||
103
+ existsSync(resolve(root, 'requirements.txt')) ||
104
+ existsSync(resolve(root, 'uv.lock')))
105
+ const js = profile.javascript === 'true' && existsSync(resolve(root, 'package.json'))
106
+ const native =
107
+ rust ||
108
+ (['format', 'lint'].includes(task) && python) ||
109
+ (task === 'type_check' && existsSync(resolve(root, 'tsconfig.json'))) ||
110
+ (js && task === 'format' && hasNativeToolSetup(root, pkg, 'oxfmt')) ||
111
+ (js && task === 'lint' && hasNativeToolSetup(root, pkg, 'oxlint'))
112
+ if (!native) {
113
+ applicable = false
114
+ reason = 'repository detected, but no executable script or supported native fallback exists'
115
+ }
116
+ }
117
+ if (required && !applicable) throw new Error(`Required capability ${task}: ${reason}`)
118
+ return {
119
+ task,
120
+ applicable,
121
+ required,
122
+ reason,
123
+ source: script ? `package-script:${script}` : 'runtime',
124
+ }
125
+ }
126
+
127
+ /** @param {string} root @returns {string[]} */
128
+ function repositoryFiles(root) {
129
+ const result = spawnSync('git', ['ls-files'], { cwd: root, encoding: 'utf8' })
130
+ return result.status === 0 ? result.stdout.split(/\r?\n/).filter(Boolean) : []
131
+ }
132
+
133
+ /** @param {string} root @param {Record<string, any>} pkg @param {'oxfmt'|'oxlint'} tool */
134
+ function hasNativeToolSetup(root, pkg, tool) {
135
+ const dependencies = {
136
+ ...pkg.dependencies,
137
+ ...pkg.devDependencies,
138
+ ...pkg.optionalDependencies,
139
+ ...pkg.peerDependencies,
140
+ }
141
+ if (dependencies[tool]) return true
142
+ if (
143
+ Object.values(pkg.scripts ?? {}).some((value) =>
144
+ new RegExp(`\\b${tool}\\b`).test(String(value))
145
+ )
146
+ )
147
+ return true
148
+ const config =
149
+ tool === 'oxfmt'
150
+ ? /(^|\/)(\.oxfmtrc\.json|oxfmt\.config\.[^/]*)$/
151
+ : /(^|\/)(\.oxlintrc\.json|oxlint\.config\.[^/]*)$/
152
+ return repositoryFiles(root).some((file) => config.test(file))
153
+ }
154
+
155
+ /** Repository-owned evidence must never escape the checkout through paths or symlinks.
156
+ * @param {string} root @param {string} file
157
+ */
158
+ export function evidencePath(root, file) {
159
+ if (!file || isAbsolute(file)) throw new Error('Evidence paths must be repository-relative')
160
+ const base = realpathSync(root)
161
+ const target = resolve(base, file)
162
+ const inside = relative(base, target)
163
+ if (inside === '..' || inside.startsWith(`..${sep}`))
164
+ throw new Error(`Evidence path escapes repository: ${file}`)
165
+
166
+ // Resolve the deepest existing ancestor so a missing report cannot hide
167
+ // behind a symlinked directory outside the checkout.
168
+ let existing = target
169
+ while (!existsSync(existing)) {
170
+ const parent = resolve(existing, '..')
171
+ if (parent === existing) break
172
+ existing = parent
173
+ }
174
+ const actual = relative(base, realpathSync(existing))
175
+ if (actual === '..' || actual.startsWith(`..${sep}`))
176
+ throw new Error(`Evidence symlink escapes repository: ${file}`)
177
+ if (existsSync(target) && !statSync(target).isFile())
178
+ throw new Error(`Evidence must be a regular file: ${file}`)
179
+ return target
180
+ }
181
+
182
+ /** @param {string} root @param {ReturnType<typeof readTaskPolicy>} policy */
183
+ export function coverageFiles(root, policy) {
184
+ const configured = listValue(policy.config.coverage_report ?? '')
185
+ return (
186
+ configured.length ? configured : ['coverage/coverage-summary.json', 'coverage/lcov.info']
187
+ ).map((file) => ({ file, path: evidencePath(root, file) }))
188
+ }
189
+
190
+ /** @param {string} path */
191
+ export function fingerprint(path) {
192
+ if (!existsSync(path)) return null
193
+ const stat = statSync(path)
194
+ return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}:${createHash('sha256').update(readFileSync(path)).digest('hex')}`
195
+ }
196
+
197
+ /** @param {number} total @param {number} covered @param {string} label */
198
+ function metric(total, covered, label) {
199
+ if (
200
+ !Number.isSafeInteger(total) ||
201
+ !Number.isSafeInteger(covered) ||
202
+ total < 0 ||
203
+ covered < 0 ||
204
+ covered > total
205
+ )
206
+ throw new Error(`Invalid coverage counts for ${label}`)
207
+ return { total, covered, percent: total ? (covered * 100) / total : null }
208
+ }
209
+
210
+ /** @param {string} content @param {'json'|'lcov'} format */
211
+ export function parseCoverage(content, format) {
212
+ /** @type {Record<string, {total: number, covered: number, percent: number|null}>} */
213
+ const metrics = {}
214
+ if (format === 'json') {
215
+ const report = JSON.parse(content)
216
+ if (!report?.total || typeof report.total !== 'object')
217
+ throw new Error('Coverage summary is missing total')
218
+ for (const name of ['lines', 'functions', 'branches', 'statements']) {
219
+ if (report.total[name])
220
+ metrics[name] = metric(report.total[name].total, report.total[name].covered, name)
221
+ }
222
+ } else {
223
+ const fields = { lines: ['LF', 'LH'], functions: ['FNF', 'FNH'], branches: ['BRF', 'BRH'] }
224
+ const records = content.split(/end_of_record\s*(?:\r?\n|$)/).filter((record) => record.trim())
225
+ if (!records.length) throw new Error('LCOV report contains no records')
226
+ for (const record of records) {
227
+ if (!/^SF:.+/m.test(record)) throw new Error('LCOV record is missing SF')
228
+ for (const [name, [totalKey, hitKey]] of Object.entries(fields)) {
229
+ const totalMatch = record.match(new RegExp(`^${totalKey}:(\\d+)\\r?$`, 'm'))
230
+ const hitMatch = record.match(new RegExp(`^${hitKey}:(\\d+)\\r?$`, 'm'))
231
+ if (!totalMatch && !hitMatch) continue
232
+ if (!totalMatch || !hitMatch) throw new Error(`Incomplete LCOV ${name} counts`)
233
+ const current = metric(Number(totalMatch[1]), Number(hitMatch[1]), name)
234
+ const previous = metrics[name] ?? { total: 0, covered: 0 }
235
+ metrics[name] = metric(
236
+ previous.total + current.total,
237
+ previous.covered + current.covered,
238
+ name
239
+ )
240
+ }
241
+ }
242
+ }
243
+ if (!metrics.lines?.total) throw new Error('Coverage report measured no lines')
244
+ return metrics
245
+ }
246
+
247
+ /**
248
+ * Auto enforces present reports but reports absence explicitly. Required mode also
249
+ * rejects absent/stale reports. Reports are never deleted to manufacture freshness.
250
+ * @param {string} root
251
+ * @param {ReturnType<typeof readTaskPolicy>} policy
252
+ * @param {Record<string, string|null>} before
253
+ */
254
+ export function evaluateCoverage(root, policy, before) {
255
+ if (policy.coverageMode === 'off')
256
+ return { status: 'skipped', reason: 'coverage enforcement explicitly disabled', artifacts: [] }
257
+ const files = coverageFiles(root, policy).filter(({ path }) => existsSync(path))
258
+ if (!files.length) {
259
+ if (policy.coverageRequired) throw new Error('Required coverage report was not produced')
260
+ return {
261
+ status: 'skipped',
262
+ reason: 'no coverage report; set coverage_enforcement: required to require evidence',
263
+ artifacts: [],
264
+ }
265
+ }
266
+ const reports = files.map(({ file, path }) => {
267
+ if (fingerprint(path) === before[file])
268
+ throw new Error(`Coverage report was not refreshed by this run: ${file}`)
269
+ const metrics = parseCoverage(
270
+ readFileSync(path, 'utf8'),
271
+ file.endsWith('.info') ? 'lcov' : 'json'
272
+ )
273
+ for (const name of policy.metrics) {
274
+ const value = metrics[name]
275
+ if (!value || value.percent === null)
276
+ throw new Error(`Coverage report has no measured ${name}: ${file}`)
277
+ if (value.percent < policy.minimum)
278
+ throw new Error(
279
+ `${name} coverage ${value.percent.toFixed(2)}% is below ${policy.minimum}% (${file})`
280
+ )
281
+ }
282
+ return { file, metrics }
283
+ })
284
+ return {
285
+ status: 'passed',
286
+ reason: 'fresh coverage meets configured thresholds',
287
+ artifacts: files.map(({ file }) => file),
288
+ reports,
289
+ }
290
+ }