fini-proof 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/ci/Jenkinsfile +1 -1
- package/ci/Jenkinsfile.daily +1 -1
- package/ci/bitbucket-pipelines-daily.yml +1 -1
- package/ci/bitbucket-pipelines.yml +1 -1
- package/ci/github-actions-daily.yml +1 -1
- package/ci/github-actions-example.yml +1 -1
- package/ci/gitlab-ci-daily.yml +1 -1
- package/ci/gitlab-ci.yml +1 -1
- package/package.json +1 -1
- package/src/cli.mjs +42 -3
- package/src/engines/fail-open.mjs +98 -9
- package/src/engines/skip-ratchet.mjs +20 -1
- package/src/evidence.mjs +5 -0
- package/src/fix.mjs +146 -0
- package/src/runner.mjs +2 -2
package/README.md
CHANGED
|
@@ -75,6 +75,33 @@ npx fini-proof licence # lic
|
|
|
75
75
|
|
|
76
76
|
A skipped test is allowed without a baseline when a comment names why: `// SKIP-LEDGER: JIRA-123 reason`.
|
|
77
77
|
|
|
78
|
+
## Proven fixes
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx fini-proof fix # dry run: prints each finding's outcome, writes .fini-proof/fixes/<run id>.patch
|
|
82
|
+
npx fini-proof fix --apply # the same, and writes the proven fixes to the working tree (never commits)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`fix` runs the same check, then for each finding does one of three things:
|
|
86
|
+
|
|
87
|
+
- **`fixed (proven)`** — a deterministic text transform (no AI, no network; your code never leaves your machine) is
|
|
88
|
+
applied to a throw-away copy of the repository and proved there by the same engines: the unfixed file still
|
|
89
|
+
reproduces the finding (red), `prove` no longer finds it (green), a whole `check` of that file raises no new finding
|
|
90
|
+
from any engine, and the rule's negative control is still caught (the check can still fail).
|
|
91
|
+
- **`manual`** — no safe transform exists for that rule (or that instance); the engine's own FIX text is shown.
|
|
92
|
+
- **`refused (fix did not prove)`** — a transform existed but failed one of the proofs; nothing is changed.
|
|
93
|
+
|
|
94
|
+
Scope is deliberately small. Fixers exist only for: `OR_TRUE` (drop `|| true` / `|| exit 0` / `--passWithNoTests`),
|
|
95
|
+
`SET_PLUS_E` (→ `set -e`), `CONTINUE_ON_ERROR` / `ALLOW_FAILURE` (→ `false`), `CATCH_EXIT_ZERO` (→ `process.exit(1)`),
|
|
96
|
+
`EMPTY_CATCH` (rethrow; a swallowing `.catch(() => {})` is removed), `EXCEPT_PASS` (`pass` → `raise`, `exit(0)` →
|
|
97
|
+
`exit(1)`) and `FOCUSED_TEST` (`it.only(` → `it(`). Everything else — hollow tests, secrets, tenant filters, every
|
|
98
|
+
migration rule, skipped tests, entry-point guards, `except: return 0` — needs a human decision and stays `manual`.
|
|
99
|
+
A proven fix makes the failure visible again; it does not make your tests pass. Review the patch like any other diff.
|
|
100
|
+
|
|
101
|
+
The evidence record of a `fix` run carries an optional `fixes` field (`mode`, `summary`, and per finding `status`,
|
|
102
|
+
`proof { red, finding_gone, no_new_findings, negative_control }`, before/after sha256). It is additive, so the schema
|
|
103
|
+
stays `evidence@2` and the digest covers it.
|
|
104
|
+
|
|
78
105
|
## Evidence file (evidence@2)
|
|
79
106
|
|
|
80
107
|
Every `check` writes one JSON record (`.fini-proof/runs/<run id>.json`, or `--json-out` / `--evidence <file>`).
|
|
@@ -178,7 +205,7 @@ Add `.fini-proof/runs/` to `.gitignore`. Every check writes an evidence file the
|
|
|
178
205
|
The agent that wrote the code does not decide whether it is done.
|
|
179
206
|
|
|
180
207
|
- **MCP server:** `npx fini-proof mcp` gives the agent three tools: `check`, `explain_finding` and `list_rules`.
|
|
181
|
-
Claude Code: `claude mcp add fini-proof -- npx --yes fini-proof@0.
|
|
208
|
+
Claude Code: `claude mcp add fini-proof -- npx --yes fini-proof@0.4.0 mcp`.
|
|
182
209
|
- **Claude Code hooks:** see `integrations/claude-code/`. The Stop hook exits 2 with the findings while the verdict is
|
|
183
210
|
FAIL, so Claude cannot finish. PostToolUse gives feedback on each file as it is written.
|
|
184
211
|
- **Codex and other agents:** see `integrations/codex/` for an `AGENTS.md` section and a CI gate. The gate also fails
|
|
@@ -200,7 +227,7 @@ Developer workspace with `check --upload` (set `FINI_PROOF_API_KEY` as a CI secr
|
|
|
200
227
|
schedule in the file; GitLab and Bitbucket need a schedule created once in their UI (the file says where). A failed
|
|
201
228
|
upload is a warning and never changes the exit code.
|
|
202
229
|
|
|
203
|
-
Each one is one command: `npx --yes fini-proof@0.
|
|
230
|
+
Each one is one command: `npx --yes fini-proof@0.4.0 check --base origin/<target> --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json`.
|
|
204
231
|
Clone with full history (`fetch-depth: 0`, `GIT_DEPTH: 0`, `depth: full`). A shallow clone gives NOT_MEASURED, not a guessed PASS.
|
|
205
232
|
`test/ci-templates.test.mjs` runs every one of these commands against a real git repository (a clean pull request must pass, a defective one must fail). The daily templates are run the same way on the default branch, uploading to a local stand-in server.
|
|
206
233
|
|
package/ci/Jenkinsfile
CHANGED
|
@@ -5,7 +5,7 @@ stage('Fini Proof') {
|
|
|
5
5
|
FINI_PROOF_HOME = "${env.WORKSPACE}/.fini-proof-home"
|
|
6
6
|
}
|
|
7
7
|
steps {
|
|
8
|
-
sh 'npx --yes fini-proof@0.
|
|
8
|
+
sh 'npx --yes fini-proof@0.4.0 check --base "origin/${CHANGE_TARGET:-main}" --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json'
|
|
9
9
|
}
|
|
10
10
|
post {
|
|
11
11
|
always { archiveArtifacts artifacts: 'fini-proof.sarif, fini-proof-evidence.json', allowEmptyArchive: true }
|
package/ci/Jenkinsfile.daily
CHANGED
|
@@ -13,7 +13,7 @@ pipeline {
|
|
|
13
13
|
stage('Fini Proof (daily)') {
|
|
14
14
|
when { anyOf { branch 'main'; branch 'master' } }
|
|
15
15
|
steps {
|
|
16
|
-
sh 'npx --yes fini-proof@0.
|
|
16
|
+
sh 'npx --yes fini-proof@0.4.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json'
|
|
17
17
|
}
|
|
18
18
|
post {
|
|
19
19
|
always { archiveArtifacts artifacts: 'fini-proof.sarif, fini-proof-evidence.json', allowEmptyArchive: true }
|
|
@@ -11,5 +11,5 @@ pipelines:
|
|
|
11
11
|
clone: { depth: full }
|
|
12
12
|
script:
|
|
13
13
|
- export FINI_PROOF_HOME="$BITBUCKET_CLONE_DIR/.fini-proof-home"
|
|
14
|
-
- npx --yes fini-proof@0.
|
|
14
|
+
- npx --yes fini-proof@0.4.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
|
|
15
15
|
artifacts: [fini-proof.sarif, fini-proof-evidence.json]
|
|
@@ -8,5 +8,5 @@ pipelines:
|
|
|
8
8
|
clone: { depth: full }
|
|
9
9
|
script:
|
|
10
10
|
- export FINI_PROOF_HOME="$BITBUCKET_CLONE_DIR/.fini-proof-home"
|
|
11
|
-
- npx --yes fini-proof@0.
|
|
11
|
+
- npx --yes fini-proof@0.4.0 check --base "origin/${BITBUCKET_PR_DESTINATION_BRANCH:-main}" --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
|
|
12
12
|
artifacts: [fini-proof.sarif, fini-proof-evidence.json]
|
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
FINI_PROOF_API_KEY: ${{ secrets.FINI_PROOF_API_KEY }}
|
|
23
23
|
FINI_PROOF_LICENCE: ${{ secrets.FINI_PROOF_LICENCE }}
|
|
24
24
|
FINI_PROOF_HOME: ${{ runner.temp }}/fini-proof-home
|
|
25
|
-
run: npx --yes fini-proof@0.
|
|
25
|
+
run: npx --yes fini-proof@0.4.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
|
|
26
26
|
- if: always() && hashFiles('fini-proof.sarif') != ''
|
|
27
27
|
uses: github/codeql-action/upload-sarif@v3
|
|
28
28
|
with: { sarif_file: fini-proof.sarif, category: fini-proof-daily }
|
package/ci/gitlab-ci-daily.yml
CHANGED
|
@@ -11,7 +11,7 @@ fini-proof-daily:
|
|
|
11
11
|
GIT_DEPTH: "0"
|
|
12
12
|
FINI_PROOF_HOME: "$CI_PROJECT_DIR/.fini-proof-home"
|
|
13
13
|
script:
|
|
14
|
-
- npx --yes fini-proof@0.
|
|
14
|
+
- npx --yes fini-proof@0.4.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
|
|
15
15
|
artifacts:
|
|
16
16
|
when: always
|
|
17
17
|
paths: [fini-proof.sarif, fini-proof-evidence.json]
|
package/ci/gitlab-ci.yml
CHANGED
|
@@ -7,7 +7,7 @@ fini-proof:
|
|
|
7
7
|
GIT_DEPTH: "0"
|
|
8
8
|
FINI_PROOF_HOME: "$CI_PROJECT_DIR/.fini-proof-home"
|
|
9
9
|
script:
|
|
10
|
-
- npx --yes fini-proof@0.
|
|
10
|
+
- npx --yes fini-proof@0.4.0 check --base "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-$CI_DEFAULT_BRANCH}" --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
|
|
11
11
|
artifacts:
|
|
12
12
|
when: always
|
|
13
13
|
paths: [fini-proof.sarif, fini-proof-evidence.json]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fini-proof",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Proof-based code verification: every finding carries a runnable proof, every check has a negative control, and a check that cannot run is NOT_MEASURED, never PASS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { exportUsage, readUsage, recordRun } from './usage.mjs'
|
|
|
9
9
|
import { serveMcp } from './mcp.mjs'
|
|
10
10
|
import { runHook } from './hooks.mjs'
|
|
11
11
|
import { DEFAULT_API, gzipWanted, uploadEvidence } from './upload.mjs'
|
|
12
|
-
import { readEvidenceFile, snapshotOf } from './evidence.mjs'
|
|
12
|
+
import { computeDigest, readEvidenceFile, snapshotOf } from './evidence.mjs'
|
|
13
|
+
import { fixFindings } from './fix.mjs'
|
|
13
14
|
import { applyStaleness, deriveStates, stateDocument, stateText } from './state.mjs'
|
|
14
15
|
|
|
15
16
|
const HELP = `fini-proof ${TOOL_VERSION} — proof-based code verification
|
|
@@ -23,6 +24,10 @@ const HELP = `fini-proof ${TOOL_VERSION} — proof-based code verification
|
|
|
23
24
|
check now; with --against it runs nothing and marks STALE what changed since that evidence
|
|
24
25
|
fini-proof evidence verify <evidence.json> re-check an evidence file's digest (exit 1 = edited)
|
|
25
26
|
fini-proof prove --engine <id> --file <path> [--line <n>] re-run one finding (exit 1 = still there)
|
|
27
|
+
fini-proof fix [--apply] [--patch-out <file>] [--engine <id>]... [--only <file>]... [--base <ref>] [--evidence <file>]
|
|
28
|
+
PROVEN fixes, local and deterministic (no AI, no network). Each fix is re-proved on a throw-away
|
|
29
|
+
copy: finding gone, no new finding, negative control still caught. Dry-run by default (writes a
|
|
30
|
+
patch); --apply writes the working tree; never commits. Output: fixed (proven) · manual · refused
|
|
26
31
|
fini-proof negctl <control-id> | --all show a check can fail (planted defect → caught)
|
|
27
32
|
fini-proof baseline [--reset] write the skip ratchet baseline (can only go down)
|
|
28
33
|
fini-proof usage [--export <file>] local usage meter (runs, repos, seats)
|
|
@@ -41,7 +46,7 @@ function parse(argv) {
|
|
|
41
46
|
const a = argv[i]
|
|
42
47
|
if (!a.startsWith('--')) { o._.push(a); continue }
|
|
43
48
|
const [k, inline] = a.slice(2).split('=')
|
|
44
|
-
const flag = ['all', 'reset', 'help', 'version', 'upload', 'upload-gzip'].includes(k)
|
|
49
|
+
const flag = ['all', 'reset', 'help', 'version', 'upload', 'upload-gzip', 'apply'].includes(k)
|
|
45
50
|
const v = flag ? true : (inline ?? argv[++i])
|
|
46
51
|
if (!flag && v === undefined) throw new Error(`--${k} needs a value`)
|
|
47
52
|
if (k === 'engine') o.engine.push(v)
|
|
@@ -58,7 +63,7 @@ const err = (s) => process.stderr.write(s.endsWith('\n') ? s : s + '\n')
|
|
|
58
63
|
* The one licensed check path — used by `check`, the MCP `check` tool and the Claude Code hook, so all three enforce
|
|
59
64
|
* the same licence, write the same evidence and meter the same way. Returns { rc, result?, ent?, evidencePath?, error? }.
|
|
60
65
|
*/
|
|
61
|
-
export function executeCheck({ root, base, failOn, engines = [], only, licence, evidence, sarifOut, jsonOut, maker }) {
|
|
66
|
+
export function executeCheck({ root, base, failOn, engines = [], only, licence, evidence, sarifOut, jsonOut, maker, augment }) {
|
|
62
67
|
const unknown = engines.filter((e) => !ENGINES.some((x) => x.id === e))
|
|
63
68
|
if (unknown.length) return { rc: EXIT.USAGE, error: `unknown engine(s): ${unknown.join(', ')}` }
|
|
64
69
|
const info = repoInfo(root)
|
|
@@ -67,6 +72,8 @@ export function executeCheck({ root, base, failOn, engines = [], only, licence,
|
|
|
67
72
|
if (!ent.ok) return { rc: EXIT.LICENCE, error: `VERDICT: NOT_MEASURED — licence: ${ent.reason}` }
|
|
68
73
|
let result
|
|
69
74
|
try { result = check(root, { base, failOn, engines, only, maker }) } catch (e) { return { rc: EXIT.USAGE, error: `fini-proof: ${e.message}` } }
|
|
75
|
+
// augment (the `fix` command): adds an optional evidence@2 field BEFORE the digest, so the digest covers it
|
|
76
|
+
if (augment) { augment(result); delete result.evidenceDigest; result.evidenceDigest = computeDigest(result) }
|
|
70
77
|
result.entitlement = ent.mode === 'licence' ? { mode: 'licence', org: ent.org, seats: ent.seats, expiry: ent.expiry, licenceId: ent.licenceId } : { mode: 'trial', daysLeft: ent.daysLeft, trialEnds: ent.trialEnds }
|
|
71
78
|
const evidencePath = path.resolve(root, evidence || path.join('.fini-proof', 'runs', `${result.runId}.json`))
|
|
72
79
|
mkdirSync(path.dirname(evidencePath), { recursive: true })
|
|
@@ -120,6 +127,37 @@ function cmdProve(o) {
|
|
|
120
127
|
return EXIT.FAIL
|
|
121
128
|
}
|
|
122
129
|
|
|
130
|
+
function cmdFix(o) {
|
|
131
|
+
const root = path.resolve(o.root || process.cwd())
|
|
132
|
+
let fx = null
|
|
133
|
+
const x = executeCheck({ root, base: o.base, failOn: o.failOn, engines: o.engine, only: o.only, licence: o.licence, evidence: o.evidence,
|
|
134
|
+
augment: (result) => {
|
|
135
|
+
if (result.reason && !result.engines.length) return // nothing was listed: nothing to fix
|
|
136
|
+
fx = fixFindings(root, result, { apply: !!o.apply })
|
|
137
|
+
result.fixes = fx.record
|
|
138
|
+
} })
|
|
139
|
+
if (x.error) { err(x.error); return x.rc }
|
|
140
|
+
if (!fx) { err(`NOT_MEASURED — ${x.result.reason}`); return EXIT.NOT_MEASURED }
|
|
141
|
+
const { record, patch } = fx
|
|
142
|
+
for (const it of record.items) {
|
|
143
|
+
const where = `${it.file}${it.line ? `:${it.line}` : ''}`
|
|
144
|
+
if (it.status === 'fixed') out(`fixed (proven) ${it.property_id} ${where}`)
|
|
145
|
+
else if (it.status === 'refused') out(`refused (fix did not prove) ${it.property_id} ${where} — ${it.reason}`)
|
|
146
|
+
else out(`manual ${it.property_id} ${where} — FIX: ${it.fix}`)
|
|
147
|
+
}
|
|
148
|
+
let patchPath = null
|
|
149
|
+
if (patch) {
|
|
150
|
+
patchPath = path.resolve(root, o.patchOut || path.join('.fini-proof', 'fixes', `${x.result.runId}.patch`))
|
|
151
|
+
mkdirSync(path.dirname(patchPath), { recursive: true })
|
|
152
|
+
writeFileSync(patchPath, patch)
|
|
153
|
+
}
|
|
154
|
+
const s = record.summary
|
|
155
|
+
out(`\n${s.fixed} fixed (proven) · ${s.manual} manual · ${s.refused} refused`)
|
|
156
|
+
if (patchPath) out(o.apply ? `applied to the working tree (not committed); patch: ${path.relative(process.cwd(), patchPath)}` : `dry run — nothing changed. Patch: ${path.relative(process.cwd(), patchPath)} (git apply it, or re-run with --apply)`)
|
|
157
|
+
out(`evidence: ${path.relative(process.cwd(), x.evidencePath)}`)
|
|
158
|
+
return EXIT.PASS
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
function cmdNegctl(o) {
|
|
124
162
|
const all = ENGINES.flatMap((e) => e.negativeControls.map((c) => ({ e, c })))
|
|
125
163
|
const pick = o.all ? all : all.filter(({ c }) => c.id === o._[1])
|
|
@@ -246,6 +284,7 @@ export function main(argv) {
|
|
|
246
284
|
case 'prove': return cmdProve(o)
|
|
247
285
|
case 'state': return cmdState(o)
|
|
248
286
|
case 'evidence': return cmdEvidence(o)
|
|
287
|
+
case 'fix': return cmdFix(o)
|
|
249
288
|
case 'negctl': return cmdNegctl(o)
|
|
250
289
|
case 'baseline': return cmdBaseline(o)
|
|
251
290
|
case 'usage': return cmdUsage(o)
|
|
@@ -39,10 +39,9 @@ function packageJsonRules(content) {
|
|
|
39
39
|
return out
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
function
|
|
42
|
+
/** `catch (…) { … }` blocks in MASKED JS: { index, open, close, binding } (offsets are the original source's). */
|
|
43
|
+
export function catchBlocks(masked) {
|
|
44
44
|
const out = []
|
|
45
|
-
const masked = maskStringsAndComments(src)
|
|
46
45
|
const re = /\bcatch\s*(\([^)]*\))?\s*\{/g
|
|
47
46
|
let m
|
|
48
47
|
while ((m = re.exec(masked)) !== null) {
|
|
@@ -52,13 +51,25 @@ function jsCatchRules(src) {
|
|
|
52
51
|
if (masked[i] === '{') depth++
|
|
53
52
|
else if (masked[i] === '}' && --depth === 0) { close = i; break }
|
|
54
53
|
}
|
|
55
|
-
if (close
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
if (close >= 0) out.push({ index: m.index, open, close, binding: m[1] ? m[1].slice(1, -1).trim() : null })
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
const PROMISE_CATCH_RE = /\.catch\s*\(\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>\s*(\{\s*\}|null|undefined|void 0|\(\s*\)|false|true)\s*\)/g
|
|
59
|
+
const EXIT_ZERO_RE = /process\.exit\s*\(\s*0?\s*\)/
|
|
60
|
+
|
|
61
|
+
/** Find `catch (e) { … }` / `.catch(() => …)` bodies in masked JS and flag the fail-open shapes. */
|
|
62
|
+
function jsCatchRules(src) {
|
|
63
|
+
const out = []
|
|
64
|
+
const masked = maskStringsAndComments(src)
|
|
65
|
+
let m
|
|
66
|
+
for (const b of catchBlocks(masked)) {
|
|
67
|
+
const body = masked.slice(b.open + 1, b.close)
|
|
68
|
+
const line = lineOf(src, b.index)
|
|
69
|
+
if (EXIT_ZERO_RE.test(body)) out.push({ line, rule: 'CATCH_EXIT_ZERO', severity: 'high', message: 'the error handler exits 0 — the script reports success exactly when it failed. FIX: exit non-zero (or rethrow) in the catch.' })
|
|
59
70
|
else if (body.trim() === '') out.push({ line, rule: 'EMPTY_CATCH', severity: 'medium', message: 'an empty catch swallows the error — the script carries on as if it succeeded. FIX: rethrow, or log and exit non-zero.' })
|
|
60
71
|
}
|
|
61
|
-
const pc =
|
|
72
|
+
const pc = new RegExp(PROMISE_CATCH_RE.source, 'g')
|
|
62
73
|
while ((m = pc.exec(masked)) !== null) out.push({ line: lineOf(src, m.index), rule: 'EMPTY_CATCH', severity: 'medium', message: '`.catch(() => {})` swallows the rejection — the script carries on as if it succeeded. FIX: let it reject, or log and exit non-zero.' })
|
|
63
74
|
return out
|
|
64
75
|
}
|
|
@@ -82,9 +93,86 @@ function pyRules(content) {
|
|
|
82
93
|
return out
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
// ── Proven fixes (`fini-proof fix`, src/fix.mjs). A fixer is a deterministic, local text transform for ONE finding:
|
|
97
|
+
// (content, finding) → new content, or null when this instance has no safe transform (→ reported as manual). A fixer
|
|
98
|
+
// never decides that it worked: src/fix.mjs proves every candidate with this engine before it is offered.
|
|
99
|
+
const OR_TRUE_RE = /\s*\|\|\s*(?:true|:|exit\s+0)\b/g
|
|
100
|
+
const lineEdit = (content, n, edit) => {
|
|
101
|
+
const lines = content.split('\n')
|
|
102
|
+
const next = edit(lines[n - 1])
|
|
103
|
+
if (next === null || next === lines[n - 1]) return null
|
|
104
|
+
lines[n - 1] = next
|
|
105
|
+
return lines.join('\n')
|
|
106
|
+
}
|
|
107
|
+
/** Split a shell/YAML line into code and trailing `# comment` (the same comment rule shellRules() uses). */
|
|
108
|
+
const codeAndComment = (l) => { const m = /(^|\s)#.*$/.exec(l); return m ? [l.slice(0, m.index), l.slice(m.index)] : [l, ''] }
|
|
109
|
+
const setFlag = (key) => (content, f) => lineEdit(content, f.line, (l) => l.replace(new RegExp(`^(\\s*${key}\\s*:\\s*)true\\b`), '$1false'))
|
|
110
|
+
|
|
111
|
+
const fixers = {
|
|
112
|
+
OR_TRUE(content, f, rel) {
|
|
113
|
+
if (/(^|\/)package\.json$/.test(rel)) {
|
|
114
|
+
return lineEdit(content, f.line, (l) => {
|
|
115
|
+
const next = l.replace(OR_TRUE_RE, '').replace(/\s+--passWithNoTests\b/g, '')
|
|
116
|
+
// the script value must still be on this line and still be a non-empty command
|
|
117
|
+
return /:\s*"\s*[^"\s]/.test(next) ? next : null
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
return lineEdit(content, f.line, (l) => {
|
|
121
|
+
const [code, comment] = codeAndComment(l)
|
|
122
|
+
const next = code.replace(OR_TRUE_RE, '')
|
|
123
|
+
if (!next.replace(/^\s*(-\s*)?(run\s*:)?/, '').trim()) return null // nothing left to run → not a safe transform
|
|
124
|
+
return next + comment
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
SET_PLUS_E: (content, f) => lineEdit(content, f.line, (l) => l.replace(/^(\s*set\s+)\+e\b/, '$1-e')),
|
|
128
|
+
CONTINUE_ON_ERROR: setFlag('continue-on-error'),
|
|
129
|
+
ALLOW_FAILURE: setFlag('allow_failure'),
|
|
130
|
+
CATCH_EXIT_ZERO(content, f) {
|
|
131
|
+
const masked = maskStringsAndComments(content)
|
|
132
|
+
const b = catchBlocks(masked).find((x) => lineOf(content, x.index) === f.line && EXIT_ZERO_RE.test(masked.slice(x.open + 1, x.close)))
|
|
133
|
+
if (!b) return null
|
|
134
|
+
const body = masked.slice(b.open + 1, b.close)
|
|
135
|
+
const re = new RegExp(EXIT_ZERO_RE.source, 'g')
|
|
136
|
+
let out = content.slice(0, b.open + 1), last = 0, m
|
|
137
|
+
while ((m = re.exec(body)) !== null) { out += content.slice(b.open + 1 + last, b.open + 1 + m.index) + 'process.exit(1)'; last = m.index + m[0].length }
|
|
138
|
+
return out + content.slice(b.open + 1 + last)
|
|
139
|
+
},
|
|
140
|
+
EMPTY_CATCH(content, f) {
|
|
141
|
+
const masked = maskStringsAndComments(content)
|
|
142
|
+
const b = catchBlocks(masked).find((x) => lineOf(content, x.index) === f.line && masked.slice(x.open + 1, x.close).trim() === '')
|
|
143
|
+
if (b) {
|
|
144
|
+
if (b.binding !== null && !/^[A-Za-z_$][\w$]*$/.test(b.binding)) return null // destructured binding: no safe rethrow
|
|
145
|
+
const name = b.binding || 'err'
|
|
146
|
+
const head = b.binding === null ? `catch (${name}) {` : content.slice(b.index, b.open + 1)
|
|
147
|
+
const raw = content.slice(b.open + 1, b.close)
|
|
148
|
+
const indent = (content.split('\n')[f.line - 1].match(/^\s*/) || [''])[0]
|
|
149
|
+
const body = raw.trim() === '' ? ` throw ${name} ` : `${raw.replace(/\s*$/, '')}\n${indent} throw ${name}\n${indent}`
|
|
150
|
+
return content.slice(0, b.index) + head + body + content.slice(b.close)
|
|
151
|
+
}
|
|
152
|
+
// `.catch(() => {})`: remove the swallowing handler so the rejection propagates
|
|
153
|
+
const re = new RegExp(PROMISE_CATCH_RE.source, 'g')
|
|
154
|
+
let m
|
|
155
|
+
while ((m = re.exec(masked)) !== null) if (lineOf(content, m.index) === f.line) return content.slice(0, m.index) + content.slice(m.index + m[0].length)
|
|
156
|
+
return null
|
|
157
|
+
},
|
|
158
|
+
EXCEPT_PASS(content, f) {
|
|
159
|
+
const lines = content.split('\n')
|
|
160
|
+
const i = lines.findIndex((x, k) => k >= f.line && x.trim() && !/^\s*#/.test(x))
|
|
161
|
+
if (i < 0) return null
|
|
162
|
+
const [ind, stmt] = [lines[i].match(/^\s*/)[0], lines[i].trim()]
|
|
163
|
+
const rest = stmt.replace(/^\S+(\s*\([^)]*\))?/, '') // a trailing comment, if any
|
|
164
|
+
let next
|
|
165
|
+
if (stmt === 'pass' || /^pass\s+#/.test(stmt)) next = 'raise'
|
|
166
|
+
else if (/^(sys\.)?exit\(\s*0?\s*\)/.test(stmt)) next = stmt.replace(/^((?:sys\.)?exit)\(\s*0?\s*\)/, '$1(1)')
|
|
167
|
+
else return null // `return 0`: what the caller should get instead is a decision → manual
|
|
168
|
+
lines[i] = ind + (next === 'raise' ? 'raise' + rest.replace(/^pass/, '') : next)
|
|
169
|
+
return lines.join('\n')
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
|
|
85
173
|
export default {
|
|
86
174
|
id: 'fail-open',
|
|
87
|
-
version: '0.
|
|
175
|
+
version: '0.2.0',
|
|
88
176
|
title: 'Fail-open CI steps and scripts',
|
|
89
177
|
provenance: 'finipe scripts/ci/check-entrypoint-guard.mjs rawGuards() @ 4ac53b367; CI/catch rules new',
|
|
90
178
|
appliesTo: (rel) => CI_FILE_RE.test(rel) || SHELL_RE.test(rel) || /(^|\/)package\.json$/.test(rel)
|
|
@@ -98,6 +186,7 @@ export default {
|
|
|
98
186
|
if (SCRIPT_DIR_RE.test(rel)) out.push(...jsCatchRules(content))
|
|
99
187
|
return out
|
|
100
188
|
},
|
|
189
|
+
fixers,
|
|
101
190
|
rules: {
|
|
102
191
|
OR_TRUE: { severity: 'high', summary: 'a failing command is forced to success (`|| true`, `|| exit 0`, `--passWithNoTests`)', fix: 'let it fail, or handle the one expected error' },
|
|
103
192
|
SET_PLUS_E: { severity: 'medium', summary: '`set +e` turns off fail-on-error for the rest of the script', fix: 'keep set -e; capture an expected exit code explicitly' },
|
|
@@ -14,9 +14,27 @@ const PY_SKIP_RE = /@pytest\.mark\.(?:skip|skipif|xfail)\b|@unittest\.(?:skip|sk
|
|
|
14
14
|
|
|
15
15
|
function lineOf(src, idx) { return src.slice(0, idx).split('\n').length }
|
|
16
16
|
|
|
17
|
+
// Proven fix (`fini-proof fix`, src/fix.mjs): drop the focus — `it.only(` → `it(`, `fit(` → `it(`. Deterministic and
|
|
18
|
+
// local; src/fix.mjs proves the candidate with this engine before it is offered. Skips are NOT auto-fixed: un-skipping
|
|
19
|
+
// a test (or writing its SKIP-LEDGER reason) is a human decision.
|
|
20
|
+
const fixers = {
|
|
21
|
+
FOCUSED_TEST(content, f) {
|
|
22
|
+
const masked = maskStringsAndComments(content)
|
|
23
|
+
const re = new RegExp(ONLY_RE.source, 'g')
|
|
24
|
+
let m
|
|
25
|
+
while ((m = re.exec(masked)) !== null) {
|
|
26
|
+
if (lineOf(content, m.index) !== f.line) continue
|
|
27
|
+
const hit = content.slice(m.index, m.index + m[0].length)
|
|
28
|
+
const next = /\.\s*only/.test(hit) ? hit.replace(/\s*\.\s*only/, '') : hit.replace(/^f(it|describe|test)/, '$1')
|
|
29
|
+
return content.slice(0, m.index) + next + content.slice(m.index + m[0].length)
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export default {
|
|
18
36
|
id: 'skip-ratchet',
|
|
19
|
-
version: '0.
|
|
37
|
+
version: '0.2.0',
|
|
20
38
|
title: 'Focused and skipped tests (ratchet)',
|
|
21
39
|
provenance: 'finipe check-vacuous-spec.mjs skip shapes + SKIP-LEDGER, fini-rule-ratchet.mjs semantics @ 4ac53b367',
|
|
22
40
|
appliesTo: (rel) => JS_TEST_RE.test(rel) || PY_TEST_RE.test(rel),
|
|
@@ -56,6 +74,7 @@ export default {
|
|
|
56
74
|
}
|
|
57
75
|
return { findings: out, measurements: { skipsPerFile: Object.fromEntries(byFile), toleratedByBaseline: tolerated } }
|
|
58
76
|
},
|
|
77
|
+
fixers,
|
|
59
78
|
rules: {
|
|
60
79
|
FOCUSED_TEST: { severity: 'high', summary: '`.only` / `fit` makes the runner skip every other test', fix: 'remove the focus' },
|
|
61
80
|
SKIPPED_TEST: { severity: 'info', summary: 'a skipped test with no `SKIP-LEDGER: <ref>` reason (tolerated up to the baseline)', fix: 'un-skip, or add a SKIP-LEDGER comment' },
|
package/src/evidence.mjs
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// .scope_files = sorted paths the engine read (scan scope + schema/context files) — Proof.scope
|
|
14
14
|
// attestation { maker: { kind: human|agent|unknown, id, model?, session?, source },
|
|
15
15
|
// verifier: { tool, version, engine_digests, runner: { host_digest, ci } } }
|
|
16
|
+
// fixes OPTIONAL, written only by `fini-proof fix` (0.4.0): { mode: dry-run|apply, patch_sha256, summary:
|
|
17
|
+
// { fixed, manual, refused }, items[]: { property_id, file, line, status: fixed|manual|refused,
|
|
18
|
+
// proof?: { red, finding_gone, no_new_findings, negative_control: { id, caught } }, before_sha256?,
|
|
19
|
+
// after_sha256?, reason?, fix? } }. An additive optional field, so the schema stays evidence@2: every
|
|
20
|
+
// @2 reader ignores unknown fields, and the digest rule is unchanged (the field is inside the digest).
|
|
16
21
|
import { createHash } from 'node:crypto'
|
|
17
22
|
import { readFileSync } from 'node:fs'
|
|
18
23
|
import os from 'node:os'
|
package/src/fix.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `fini-proof fix` — PROVEN fixes. No LLM, no network: every candidate is a deterministic text transform that lives
|
|
2
|
+
// next to its rule (engine.fixers[RULE], today in engines/fail-open.mjs and engines/skip-ratchet.mjs), and nothing is
|
|
3
|
+
// offered until the SAME engines prove it on a throw-away copy of the repository:
|
|
4
|
+
// 1. red — the unfixed file, re-scanned in the copy, still raises the rule at that line (the defect is real);
|
|
5
|
+
// 2. green — with the candidate, `prove` no longer finds the rule at that line and the rule's count in the file
|
|
6
|
+
// dropped by exactly one;
|
|
7
|
+
// 3. no regressions — a whole `check --only <file>` over the candidate raises no finding (any engine, any rule)
|
|
8
|
+
// that the unfixed file did not, and no engine becomes NOT_MEASURED;
|
|
9
|
+
// 4. the check can still fail — the rule's negative control is still caught.
|
|
10
|
+
// Anything else is `refused (fix did not prove)`. A rule with no fixer, or an instance a fixer cannot transform
|
|
11
|
+
// safely, is `manual` with the engine's own FIX text — never guessed.
|
|
12
|
+
//
|
|
13
|
+
// REUSE NOTE: there is no second rule model here. Detection, proof and controls are runner.mjs check()/prove()/
|
|
14
|
+
// runControl() and the engines' own rule tables; this file only orchestrates them and writes the diff.
|
|
15
|
+
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import os from 'node:os'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { ENGINES, check, prove, runControl, sha256 } from './runner.mjs'
|
|
19
|
+
|
|
20
|
+
/** A minimal, correct unified diff of one file (one hunk spanning every change, 3 lines of context). */
|
|
21
|
+
export function unifiedDiff(rel, a, b, context = 3) {
|
|
22
|
+
if (a === b) return ''
|
|
23
|
+
const split = (s) => (s.endsWith('\n') ? s.slice(0, -1) : s).split('\n')
|
|
24
|
+
const A = split(a), B = split(b)
|
|
25
|
+
let pre = 0
|
|
26
|
+
while (pre < A.length && pre < B.length && A[pre] === B[pre]) pre++
|
|
27
|
+
let suf = 0
|
|
28
|
+
while (suf < A.length - pre && suf < B.length - pre && A[A.length - 1 - suf] === B[B.length - 1 - suf]) suf++
|
|
29
|
+
// a changed final-newline state must stay inside the hunk
|
|
30
|
+
if (a.endsWith('\n') !== b.endsWith('\n') && suf > 0) suf = 0
|
|
31
|
+
const s = Math.max(0, pre - context)
|
|
32
|
+
const eA = Math.min(A.length, A.length - suf + context), eB = Math.min(B.length, B.length - suf + context)
|
|
33
|
+
const out = [`--- a/${rel}`, `+++ b/${rel}`, `@@ -${s + 1},${eA - s} +${s + 1},${eB - s} @@`]
|
|
34
|
+
const noNl = '\'
|
|
35
|
+
const ctxLine = (i) => { out.push(` ${A[i]}`); if (i === A.length - 1 && !a.endsWith('\n')) out.push(noNl) }
|
|
36
|
+
for (let i = s; i < pre; i++) ctxLine(i)
|
|
37
|
+
for (let i = pre; i < A.length - suf; i++) { out.push(`-${A[i]}`); if (i === A.length - 1 && !a.endsWith('\n')) out.push(noNl) }
|
|
38
|
+
for (let i = pre; i < B.length - suf; i++) { out.push(`+${B[i]}`); if (i === B.length - 1 && !b.endsWith('\n')) out.push(noNl) }
|
|
39
|
+
for (let i = A.length - suf; i < eA; i++) ctxLine(i)
|
|
40
|
+
return out.join('\n') + '\n'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The engine's own FIX guidance for a finding (the message's FIX: part, else the rule table's fix). */
|
|
44
|
+
export function fixText(engine, f) {
|
|
45
|
+
const m = /FIX:\s*(.+)$/s.exec(f.message || '')
|
|
46
|
+
return (m ? m[1] : engine?.rules?.[f.rule]?.fix || 'see the finding message').trim()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Per-property finding counts of a whole `check --only <rel>` over `root`, plus the engines that were measured. */
|
|
50
|
+
function countsOf(root, rel) {
|
|
51
|
+
const r = check(root, { only: [rel], failOn: 'none' })
|
|
52
|
+
const counts = {}
|
|
53
|
+
for (const f of r.findings) if (f.file === rel) counts[f.property_id] = (counts[f.property_id] || 0) + 1
|
|
54
|
+
const measured = new Set(r.engines.filter((e) => e.status === 'MEASURED').map((e) => e.id))
|
|
55
|
+
return { counts, measured }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Prove one candidate on the throw-away copy `work`. The copy holds `before` for `rel` on entry and on a refusal,
|
|
60
|
+
* `candidate` on success. Returns { ok, proof, reason? }.
|
|
61
|
+
*/
|
|
62
|
+
export function proveFix(work, { engine, rule, rel, line, before, candidate }) {
|
|
63
|
+
const file = path.join(work, rel)
|
|
64
|
+
const pid = `${engine.id}/${rule}`
|
|
65
|
+
const proof = { red: false, finding_gone: false, no_new_findings: false, negative_control: null }
|
|
66
|
+
const refuse = (reason) => { writeFileSync(file, before); return { ok: false, proof, reason } }
|
|
67
|
+
writeFileSync(file, before)
|
|
68
|
+
const red = prove(work, engine.id, rel, line)
|
|
69
|
+
if (red.status !== 'OK') return refuse(`NOT_MEASURED before the fix: ${red.reason}`)
|
|
70
|
+
proof.red = red.hits.some((h) => h.rule === rule)
|
|
71
|
+
if (!proof.red) return refuse(`the unfixed file does not reproduce ${pid} at line ${line}`)
|
|
72
|
+
const was = countsOf(work, rel)
|
|
73
|
+
writeFileSync(file, candidate)
|
|
74
|
+
const green = prove(work, engine.id, rel, line)
|
|
75
|
+
if (green.status !== 'OK') return refuse(`NOT_MEASURED after the fix: ${green.reason}`)
|
|
76
|
+
const now = countsOf(work, rel)
|
|
77
|
+
proof.finding_gone = !green.hits.some((h) => h.rule === rule) && (now.counts[pid] || 0) === (was.counts[pid] || 0) - 1
|
|
78
|
+
if (!proof.finding_gone) return refuse(`${pid} is still raised after the fix`)
|
|
79
|
+
const added = Object.entries(now.counts).filter(([k, n]) => n > (was.counts[k] || 0)).map(([k, n]) => `${k} ${was.counts[k] || 0}→${n}`)
|
|
80
|
+
const lost = [...was.measured].filter((id) => !now.measured.has(id))
|
|
81
|
+
proof.no_new_findings = added.length === 0 && lost.length === 0
|
|
82
|
+
if (added.length) return refuse(`the fix raises a new finding: ${added.join(', ')}`)
|
|
83
|
+
if (lost.length) return refuse(`the fix makes ${lost.join(', ')} NOT_MEASURED on this file`)
|
|
84
|
+
const nc = engine.negativeControls.find((c) => c.expect === rule)
|
|
85
|
+
if (!nc) return refuse(`${pid} has no negative control — a fix of a check that cannot be shown to fail is not proven`)
|
|
86
|
+
const ncr = runControl(engine, nc, 'negative', work)
|
|
87
|
+
proof.negative_control = { id: nc.id, caught: ncr.ok }
|
|
88
|
+
if (!ncr.ok) return refuse(`negative control ${nc.id} is no longer caught`)
|
|
89
|
+
return { ok: true, proof }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Plan (and optionally apply) proven fixes for the findings of one check result. Never commits; writes to the
|
|
94
|
+
* working tree only with apply. Returns the evidence `fixes` record plus the patch text.
|
|
95
|
+
*/
|
|
96
|
+
export function fixFindings(root, result, { apply = false } = {}) {
|
|
97
|
+
const items = []
|
|
98
|
+
const byFile = new Map()
|
|
99
|
+
for (const f of result.findings) {
|
|
100
|
+
const engine = ENGINES.find((e) => e.id === f.engine)
|
|
101
|
+
if (!engine?.fixers?.[f.rule] || typeof f.line !== 'number') {
|
|
102
|
+
items.push({ property_id: f.property_id, file: f.file, line: f.line, status: 'manual', fix: fixText(engine, f) })
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (!byFile.has(f.file)) byFile.set(f.file, [])
|
|
106
|
+
byFile.get(f.file).push({ f, engine })
|
|
107
|
+
}
|
|
108
|
+
let patch = ''
|
|
109
|
+
if (byFile.size) {
|
|
110
|
+
const work = mkdtempSync(path.join(os.tmpdir(), 'fini-proof-fix-'))
|
|
111
|
+
try {
|
|
112
|
+
cpSync(root, work, { recursive: true, filter: (src) => !src.split(path.sep).includes('node_modules') })
|
|
113
|
+
for (const [rel, list] of byFile) {
|
|
114
|
+
const original = readFileSync(path.join(root, rel), 'utf8')
|
|
115
|
+
let content = original
|
|
116
|
+
// bottom-up: an edit never moves the line of a finding that is still to be fixed
|
|
117
|
+
list.sort((x, y) => y.f.line - x.f.line || x.f.rule.localeCompare(y.f.rule))
|
|
118
|
+
for (const { f, engine } of list) {
|
|
119
|
+
const base = { property_id: f.property_id, file: rel, line: f.line }
|
|
120
|
+
const candidate = engine.fixers[f.rule](content, f, rel)
|
|
121
|
+
if (candidate === null || candidate === content) { items.push({ ...base, status: 'manual', reason: 'no safe deterministic transform for this instance', fix: fixText(engine, f) }); continue }
|
|
122
|
+
const p = proveFix(work, { engine, rule: f.rule, rel, line: f.line, before: content, candidate })
|
|
123
|
+
if (!p.ok) { items.push({ ...base, status: 'refused', reason: p.reason, proof: p.proof, fix: fixText(engine, f) }); continue }
|
|
124
|
+
items.push({ ...base, status: 'fixed', proof: p.proof, before_sha256: sha256(content), after_sha256: sha256(candidate) })
|
|
125
|
+
content = candidate
|
|
126
|
+
}
|
|
127
|
+
if (content === original) continue
|
|
128
|
+
if (apply) {
|
|
129
|
+
// the working tree must still hold the bytes that were proven against
|
|
130
|
+
const cur = readFileSync(path.join(root, rel), 'utf8')
|
|
131
|
+
if (cur !== original) {
|
|
132
|
+
for (const it of items) if (it.file === rel && it.status === 'fixed') Object.assign(it, { status: 'refused', reason: `${rel} changed while fixing; nothing written` })
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
writeFileSync(path.join(root, rel), content)
|
|
136
|
+
}
|
|
137
|
+
patch += unifiedDiff(rel, original, content)
|
|
138
|
+
}
|
|
139
|
+
} finally { rmSync(work, { recursive: true, force: true }) }
|
|
140
|
+
}
|
|
141
|
+
const n = (s) => items.filter((i) => i.status === s).length
|
|
142
|
+
return {
|
|
143
|
+
record: { mode: apply ? 'apply' : 'dry-run', patch_sha256: patch ? sha256(patch) : null, summary: { fixed: n('fixed'), manual: n('manual'), refused: n('refused') }, items },
|
|
144
|
+
patch,
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/runner.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import skipRatchet from './engines/skip-ratchet.mjs'
|
|
|
17
17
|
import tenantFilter from './engines/tenant-filter.mjs'
|
|
18
18
|
import migrations from './engines/migrations.mjs'
|
|
19
19
|
|
|
20
|
-
export const TOOL_VERSION = '0.
|
|
20
|
+
export const TOOL_VERSION = '0.4.0'
|
|
21
21
|
// Order matters in one place: an orchestrated engine runs before the built-in engine it `replaces` (gitleaks → secrets).
|
|
22
22
|
export const ENGINES = [hollow, gitleaks, secrets, failOpen, skipRatchet, tenantFilter, migrations]
|
|
23
23
|
const ENGINE_FILES = {
|
|
@@ -300,7 +300,7 @@ export function prove(root, engineId, file, line = null) {
|
|
|
300
300
|
export function ruleCatalogue() {
|
|
301
301
|
return ENGINES.map((e) => ({
|
|
302
302
|
engine: e.id, version: e.version, title: e.title, provenance: e.provenance,
|
|
303
|
-
rules: Object.entries(e.rules || {}).map(([id, r]) => ({ id, ...r, negativeControl: (e.negativeControls.find((c) => c.expect === id) || {}).id || null })),
|
|
303
|
+
rules: Object.entries(e.rules || {}).map(([id, r]) => ({ id, ...r, provenFix: !!e.fixers?.[id], negativeControl: (e.negativeControls.find((c) => c.expect === id) || {}).id || null })),
|
|
304
304
|
negativeControls: e.negativeControls.map((c) => c.id),
|
|
305
305
|
}))
|
|
306
306
|
}
|