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 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.3.0 mcp`.
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.3.0 check --base origin/<target> --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json`.
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.3.0 check --base "origin/${CHANGE_TARGET:-main}" --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json'
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 }
@@ -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.3.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json'
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.3.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
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.3.0 check --base "origin/${BITBUCKET_PR_DESTINATION_BRANCH:-main}" --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
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.3.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
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 }
@@ -9,6 +9,6 @@ jobs:
9
9
  steps:
10
10
  - uses: actions/checkout@v4
11
11
  with: { fetch-depth: 0 }
12
- - uses: finipe/fini-proof@v0.3.0
12
+ - uses: finipe/fini-proof@v0.4.0
13
13
  with:
14
14
  licence: ${{ secrets.FINI_PROOF_LICENCE }}
@@ -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.3.0 check --upload --fail-on high --sarif-out fini-proof.sarif --json-out fini-proof-evidence.json
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.3.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
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.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
- /** Find `catch (e) { … }` / `.catch(() => …)` bodies in masked JS and flag the fail-open shapes. */
43
- function jsCatchRules(src) {
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 < 0) continue
56
- const body = masked.slice(open + 1, close)
57
- const line = lineOf(src, m.index)
58
- if (/process\.exit\s*\(\s*0?\s*\)/.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.' })
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 = /\.catch\s*\(\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>\s*(\{\s*\}|null|undefined|void 0|\(\s*\)|false|true)\s*\)/g
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.1.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.1.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.3.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
  }