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.
- package/.github/code-foundry.yml +4 -0
- package/.github/workflows/ci.yml +4 -0
- package/.github/workflows/codeql.yml +2 -0
- package/.github/workflows/security.yml +2 -0
- package/.github/workflows/test.yml +5 -0
- package/.github/workflows/validation-no-codeql.yml +1 -0
- package/.github/workflows/validation.yml +1 -0
- package/.github/workflows/validation_self-ci.yml +1 -0
- package/CHANGELOG.md +7 -0
- package/docs/CONFIGURATION.md +6 -1
- package/docs/README.md +1 -0
- package/docs/required-capabilities.md +82 -0
- package/package.json +1 -1
- package/src/commands/sync.mjs +4 -0
- package/src/lib/task-policy.mjs +290 -0
- package/src/runtime-core.mjs +794 -0
- package/src/runtime.mjs +179 -764
package/.github/code-foundry.yml
CHANGED
|
@@ -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
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -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: |
|
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
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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` |
|
|
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.
|
|
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": {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -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
|
+
}
|