fluidfix 0.1.0__py3-none-any.whl
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.
- fluidfix/__init__.py +35 -0
- fluidfix/acts.py +160 -0
- fluidfix/cli.py +186 -0
- fluidfix/guard.py +157 -0
- fluidfix/lanes.py +45 -0
- fluidfix/localize.py +167 -0
- fluidfix/loop.py +127 -0
- fluidfix/observers.py +167 -0
- fluidfix/oracle.py +86 -0
- fluidfix/router.py +51 -0
- fluidfix-0.1.0.dist-info/METADATA +190 -0
- fluidfix-0.1.0.dist-info/RECORD +17 -0
- fluidfix-0.1.0.dist-info/WHEEL +5 -0
- fluidfix-0.1.0.dist-info/entry_points.txt +2 -0
- fluidfix-0.1.0.dist-info/licenses/COMMERCIAL.md +28 -0
- fluidfix-0.1.0.dist-info/licenses/LICENSE +662 -0
- fluidfix-0.1.0.dist-info/top_level.txt +1 -0
fluidfix/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 devkancheti4-design
|
|
3
|
+
"""fluidfix — zero-token repair decisions for mechanical single-line bugs.
|
|
4
|
+
|
|
5
|
+
from fluidfix import Oracle, build_packet, MechanicalObserver, repair
|
|
6
|
+
|
|
7
|
+
oracle = Oracle("path/to/project", python="path/to/venv/bin/python")
|
|
8
|
+
packet = build_packet(oracle, "pkg/module.py")
|
|
9
|
+
observations = MechanicalObserver().observe([packet])[0]
|
|
10
|
+
result = repair(oracle, "pkg/module.py", observations)
|
|
11
|
+
print(result.summary())
|
|
12
|
+
"""
|
|
13
|
+
from .acts import (ACTS, KINDS, WORKED_EXAMPLE, Observation, act_for, apply,
|
|
14
|
+
register)
|
|
15
|
+
from .guard import (GuardReport, commit_repair, find_candidate_files,
|
|
16
|
+
guard_once, write_refusal)
|
|
17
|
+
from .lanes import ADVANCE, EMIT, HALT, kind_of, mask_of
|
|
18
|
+
from .localize import Packet, build_packet
|
|
19
|
+
from .loop import RepairResult, repair
|
|
20
|
+
from .observers import ClaudeObserver, MechanicalObserver, observer_prompt
|
|
21
|
+
from .oracle import Oracle
|
|
22
|
+
from .router import pack, route, route_packed
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
__all__ = [
|
|
26
|
+
"route", "route_packed", "pack",
|
|
27
|
+
"EMIT", "ADVANCE", "HALT", "mask_of", "kind_of",
|
|
28
|
+
"Observation", "KINDS", "ACTS", "WORKED_EXAMPLE", "act_for", "apply",
|
|
29
|
+
"register",
|
|
30
|
+
"Oracle", "Packet", "build_packet",
|
|
31
|
+
"MechanicalObserver", "ClaudeObserver", "observer_prompt",
|
|
32
|
+
"repair", "RepairResult",
|
|
33
|
+
"GuardReport", "guard_once", "find_candidate_files", "commit_repair",
|
|
34
|
+
"write_refusal",
|
|
35
|
+
]
|
fluidfix/acts.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 devkancheti4-design
|
|
3
|
+
# Commercial licensing: see COMMERCIAL.md.
|
|
4
|
+
"""The act vocabulary: fault kinds, repairs, and the observation contract.
|
|
5
|
+
|
|
6
|
+
One structure defines each kind's meaning; the mechanical observer's regexes,
|
|
7
|
+
the LLM observer's prompt, and the appliers all derive from it. The router
|
|
8
|
+
never sees any of this — it maps kind -> act code from one worked example.
|
|
9
|
+
|
|
10
|
+
Observations may carry "clear data" pointers that sharpen the applier without
|
|
11
|
+
touching the router:
|
|
12
|
+
|
|
13
|
+
literal_value/-occurrence which numeric literal is wrong (kind 1). On the
|
|
14
|
+
benchmark corpus this alone took in-vocabulary exact repairs from 17/27
|
|
15
|
+
(first-literal heuristic) to 26/27.
|
|
16
|
+
op_occurrence which additive operator is flipped (kind 3), counting binary
|
|
17
|
+
" + "/" - " left to right, 1-based. Closes the remaining measured miss:
|
|
18
|
+
a line carrying both a `+` and the faulty `-`.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
__all__ = ["Observation", "KINDS", "ACTS", "WORKED_EXAMPLE", "apply", "act_for"]
|
|
26
|
+
|
|
27
|
+
from .router import route
|
|
28
|
+
|
|
29
|
+
# The single mapping supplied to the router; every other act is inferred.
|
|
30
|
+
WORKED_EXAMPLE = (0, 5)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Observation:
|
|
35
|
+
"""What an observer reports. Never a fix, never an act code."""
|
|
36
|
+
lineno: int # 1-based line in the defect file
|
|
37
|
+
kinds: list[int] = field(default_factory=list) # most specific first; [] = refuse
|
|
38
|
+
literal_value: str | None = None
|
|
39
|
+
literal_occurrence: int | None = None
|
|
40
|
+
op_occurrence: int | None = None
|
|
41
|
+
note: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# kind -> (name, description used verbatim in observer prompts, line-signal regex)
|
|
45
|
+
KINDS = {
|
|
46
|
+
0: ("strictness",
|
|
47
|
+
'a token containing < or > has one "=" too many or too few '
|
|
48
|
+
'(a ">=" that should be ">", a "<" that should be "<=", an "->" '
|
|
49
|
+
'corrupted to "->=", including inside strings)',
|
|
50
|
+
re.compile(r"[<>]=?")),
|
|
51
|
+
1: ("literal-off-by-one",
|
|
52
|
+
"a numeric literal on the line is exactly one greater than correct "
|
|
53
|
+
"(3601 for 3600, [2:] for [1:], group(2) for group(1))",
|
|
54
|
+
re.compile(r"\d")),
|
|
55
|
+
2: ("swapped-return-operands",
|
|
56
|
+
'a "return a OP b" whose two operands are in the wrong order',
|
|
57
|
+
re.compile(r"^\s*return\s+.*\s(?://|[-+*])\s")),
|
|
58
|
+
3: ("flipped-additive",
|
|
59
|
+
'a binary " + " that should be " - ", or a " - " that should be '
|
|
60
|
+
'" + " (spaces around the operator)',
|
|
61
|
+
re.compile(r"\s[-+]\s")),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _flip_strictness(line: str, obs: Observation) -> str:
|
|
66
|
+
for a, b in ((">=", ">"), ("<=", "<")):
|
|
67
|
+
if a in line:
|
|
68
|
+
return line.replace(a, b, 1)
|
|
69
|
+
for a, b in ((">", ">="), ("<", "<=")):
|
|
70
|
+
if a in line:
|
|
71
|
+
return line.replace(a, b, 1)
|
|
72
|
+
return line
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _reduce_literal(line: str, obs: Observation) -> str:
|
|
76
|
+
m = None
|
|
77
|
+
if obs.literal_value:
|
|
78
|
+
hits = [h for h in re.finditer(r"\d+", line) if h.group() == str(obs.literal_value)]
|
|
79
|
+
occ = max(1, obs.literal_occurrence or 1)
|
|
80
|
+
if len(hits) >= occ:
|
|
81
|
+
m = hits[occ - 1]
|
|
82
|
+
if m is None:
|
|
83
|
+
m = re.search(r"\d+", line)
|
|
84
|
+
if not m:
|
|
85
|
+
return line
|
|
86
|
+
new_lit = str(int(m.group()) - 1)
|
|
87
|
+
out = line[:m.start()] + new_lit + line[m.end():]
|
|
88
|
+
# Simplify `x + 1` -> decrement -> `x + 0` -> `x`, but ONLY at the
|
|
89
|
+
# decrement site, and only when the zero stands alone: a global
|
|
90
|
+
# unanchored sub was measured to eat an unrelated `-0.5` elsewhere on
|
|
91
|
+
# the line while the suite still passed.
|
|
92
|
+
if new_lit == "0" and not re.match(r"[0-9.eEjJxXbBoO_]", line[m.end():m.end() + 1] or " "):
|
|
93
|
+
pre = re.search(r"\s*[+-]\s*$", line[:m.start()])
|
|
94
|
+
if pre:
|
|
95
|
+
out = line[:pre.start()] + line[m.end():]
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _swap_return_operands(line: str, obs: Observation) -> str:
|
|
100
|
+
m = re.match(r"^(\s*return\s+)(.*?)(\s(?://|[-+*])\s)(.*)$", line)
|
|
101
|
+
return line if not m else f"{m.group(1)}{m.group(4)}{m.group(3)}{m.group(2)}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _flip_additive(line: str, obs: Observation) -> str:
|
|
105
|
+
# Deliberate divergence from kdebug's substring match: \s admits tabs and
|
|
106
|
+
# non-overlapping scanning skips a "+" that shares a space with a
|
|
107
|
+
# preceding "-" (`a - + b`). fluidfix attempts strictly more candidates
|
|
108
|
+
# on those whitespace edge cases; the suite remains the judge.
|
|
109
|
+
ops = list(re.finditer(r"\s([+-])\s", line))
|
|
110
|
+
if not ops:
|
|
111
|
+
return line
|
|
112
|
+
if obs.op_occurrence and 1 <= obs.op_occurrence <= len(ops):
|
|
113
|
+
m = ops[obs.op_occurrence - 1]
|
|
114
|
+
else:
|
|
115
|
+
# legacy first-match heuristic, kept for observers with no pointer
|
|
116
|
+
m = next((o for o in ops if o.group(1) == "+"), ops[0])
|
|
117
|
+
flipped = "-" if m.group(1) == "+" else "+"
|
|
118
|
+
return line[:m.start(1)] + flipped + line[m.end(1):]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# act code -> applier; codes are one translation of the vocabulary. Renumber
|
|
122
|
+
# them freely — the router recovers the offset from WORKED_EXAMPLE each call.
|
|
123
|
+
ACTS = {
|
|
124
|
+
5: _flip_strictness,
|
|
125
|
+
6: _reduce_literal,
|
|
126
|
+
7: _swap_return_operands,
|
|
127
|
+
8: _flip_additive,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def act_for(kind: int) -> int:
|
|
132
|
+
"""The router's decision: kind -> act code, from the one worked example."""
|
|
133
|
+
return route(WORKED_EXAMPLE[0], WORKED_EXAMPLE[1], kind)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def register(kind: int, name: str, description: str, signal,
|
|
137
|
+
applier) -> int:
|
|
138
|
+
"""Teach fluidfix a new fault class. One entry, one transform — the router
|
|
139
|
+
is untouched: it infers this kind's act code from the same single worked
|
|
140
|
+
example, so a new class costs exactly one registration, once, and every
|
|
141
|
+
future member of the class is decided for free.
|
|
142
|
+
|
|
143
|
+
kind: 0-15 (the kernel's domain — one dictionary holds 16 classes).
|
|
144
|
+
signal: compiled regex a line must match for the mechanical observer to
|
|
145
|
+
report this kind (LLM observers receive `description` verbatim).
|
|
146
|
+
applier(line, observation) -> candidate line.
|
|
147
|
+
Returns the act code the router assigned.
|
|
148
|
+
"""
|
|
149
|
+
if not 0 <= kind <= 15:
|
|
150
|
+
raise ValueError("kind must be 0..15 — the kernel routes mod 16")
|
|
151
|
+
code = act_for(kind)
|
|
152
|
+
KINDS[kind] = (name, description, signal)
|
|
153
|
+
ACTS[code] = applier
|
|
154
|
+
return code
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply(line: str, act: int, obs: Observation) -> str:
|
|
158
|
+
"""Apply an act to a line. Unknown acts are a no-op (NOPROGRESS upstream)."""
|
|
159
|
+
fn = ACTS.get(act)
|
|
160
|
+
return line if fn is None else fn(line, obs)
|
fluidfix/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 devkancheti4-design
|
|
3
|
+
# Commercial licensing: see COMMERCIAL.md.
|
|
4
|
+
"""fluidfix CLI.
|
|
5
|
+
|
|
6
|
+
fluidfix guard ROOT [--interval 900] [--commit] [--observer mechanical|claude]
|
|
7
|
+
fluidfix repair ROOT --file pkg/mod.py [--python VENV_PY] [--observer mechanical|claude]
|
|
8
|
+
fluidfix packet ROOT --file pkg/mod.py [--python VENV_PY]
|
|
9
|
+
fluidfix selfcheck
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _oracle(args):
|
|
19
|
+
from .oracle import Oracle
|
|
20
|
+
return Oracle(args.root, python=args.python,
|
|
21
|
+
timeout=args.suite_timeout,
|
|
22
|
+
per_test_timeout=args.test_timeout)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_repair(args) -> int:
|
|
26
|
+
from .localize import build_packet
|
|
27
|
+
from .loop import repair
|
|
28
|
+
oracle = _oracle(args)
|
|
29
|
+
packet = build_packet(oracle, args.file, coverage_target=args.cov)
|
|
30
|
+
if packet is None:
|
|
31
|
+
print("suite is green — nothing to repair (refusing to search)")
|
|
32
|
+
return 3
|
|
33
|
+
if args.observer == "claude":
|
|
34
|
+
from .observers import ClaudeObserver
|
|
35
|
+
obs = ClaudeObserver(model=args.model)
|
|
36
|
+
observations = obs.observe([packet])[0]
|
|
37
|
+
if obs.last_usage is not None:
|
|
38
|
+
u = obs.last_usage
|
|
39
|
+
print(f"observer usage: in={u.input_tokens} out={u.output_tokens}",
|
|
40
|
+
file=sys.stderr)
|
|
41
|
+
else:
|
|
42
|
+
from .observers import MechanicalObserver
|
|
43
|
+
observations = MechanicalObserver().observe([packet])[0]
|
|
44
|
+
result = repair(oracle, args.file, observations,
|
|
45
|
+
candidate_timeout=args.candidate_timeout)
|
|
46
|
+
if args.json:
|
|
47
|
+
print(json.dumps(result.__dict__, default=str, indent=1))
|
|
48
|
+
else:
|
|
49
|
+
print(result.summary())
|
|
50
|
+
return 0 if result.repaired else 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _observer(args):
|
|
54
|
+
if args.observer == "claude":
|
|
55
|
+
from .observers import ClaudeObserver
|
|
56
|
+
return ClaudeObserver(model=args.model)
|
|
57
|
+
from .observers import MechanicalObserver
|
|
58
|
+
return MechanicalObserver()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def cmd_guard(args) -> int:
|
|
62
|
+
import time as _time
|
|
63
|
+
from .guard import commit_repair, guard_once, write_refusal
|
|
64
|
+
oracle = _oracle(args)
|
|
65
|
+
observer = _observer(args)
|
|
66
|
+
while True:
|
|
67
|
+
report = guard_once(oracle, observer, coverage_target=args.cov,
|
|
68
|
+
candidate_timeout=args.candidate_timeout)
|
|
69
|
+
print(f"[{_time.strftime('%H:%M:%S')}] {report.summary()}")
|
|
70
|
+
if report.status == "repaired" and args.commit:
|
|
71
|
+
print(" committed" if commit_repair(oracle.root, report)
|
|
72
|
+
else " commit failed — repair left in working tree")
|
|
73
|
+
if report.status == "refused":
|
|
74
|
+
print(f" refusal report: {write_refusal(oracle.root, report)}")
|
|
75
|
+
if args.interval is None:
|
|
76
|
+
return 0 if report.status in ("green", "repaired") else 2
|
|
77
|
+
_time.sleep(args.interval)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_packet(args) -> int:
|
|
81
|
+
from .localize import build_packet
|
|
82
|
+
packet = build_packet(_oracle(args), args.file, coverage_target=args.cov)
|
|
83
|
+
if packet is None:
|
|
84
|
+
print("suite is green — no packet")
|
|
85
|
+
return 3
|
|
86
|
+
print(packet.render())
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_selfcheck(args) -> int:
|
|
91
|
+
"""Re-derive the shipped laws from scratch. No network, no dependencies."""
|
|
92
|
+
from .lanes import ADVANCE, EMIT, HALT
|
|
93
|
+
from .router import route
|
|
94
|
+
|
|
95
|
+
bad = 0
|
|
96
|
+
for F1 in range(16):
|
|
97
|
+
for A1 in range(16):
|
|
98
|
+
for Fq in range(16):
|
|
99
|
+
want = (Fq + A1 - F1) % 16
|
|
100
|
+
bad += route(F1, A1, Fq) != want
|
|
101
|
+
print(f"router vs reference, all 4096 (F1,A1,Fq): {4096 - bad}/4096")
|
|
102
|
+
|
|
103
|
+
ident = sum(route(F1, A1, F1) != A1 for F1 in range(16) for A1 in range(16))
|
|
104
|
+
print(f"identity route(F1,A1,F1)==A1: {256 - ident}/256")
|
|
105
|
+
|
|
106
|
+
comp = sum(route(0, o2, route(0, o1, q)) != (q + o1 + o2) % 16
|
|
107
|
+
for o1 in range(16) for o2 in range(16) for q in range(16))
|
|
108
|
+
print(f"composition route(o2, route(o1, q)): {4096 - comp}/4096")
|
|
109
|
+
|
|
110
|
+
lane_bad = 0
|
|
111
|
+
for m in range(256):
|
|
112
|
+
if m and EMIT(m) != (m & -m):
|
|
113
|
+
lane_bad += 1
|
|
114
|
+
if m and ADVANCE(m) >= m:
|
|
115
|
+
lane_bad += 1
|
|
116
|
+
if HALT(m) != (1 if m == 0 else 0):
|
|
117
|
+
lane_bad += 1
|
|
118
|
+
print(f"lanes: EMIT/ADVANCE/HALT on 256 states: {'0 wrong' if not lane_bad else f'{lane_bad} WRONG'}")
|
|
119
|
+
|
|
120
|
+
steps = 0
|
|
121
|
+
for m in range(256):
|
|
122
|
+
w = m
|
|
123
|
+
while not HALT(w):
|
|
124
|
+
w = ADVANCE(w)
|
|
125
|
+
steps += 1
|
|
126
|
+
assert steps < 4096, "ADVANCE failed to reduce"
|
|
127
|
+
print(f"termination: every mask drains ({steps} total steps)")
|
|
128
|
+
print("SELFCHECK PASS" if not (bad or ident or comp or lane_bad) else "SELFCHECK FAIL")
|
|
129
|
+
return 0 if not (bad or ident or comp or lane_bad) else 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main(argv=None) -> int:
|
|
133
|
+
p = argparse.ArgumentParser(prog="fluidfix", description=__doc__)
|
|
134
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
135
|
+
|
|
136
|
+
def common(sp, need_file=True):
|
|
137
|
+
sp.add_argument("root", help="target project root (where pytest runs)")
|
|
138
|
+
if need_file:
|
|
139
|
+
sp.add_argument("--file", required=True,
|
|
140
|
+
help="defect file, relative to root")
|
|
141
|
+
sp.add_argument("--python", default=None,
|
|
142
|
+
help="target project's python (default: this one)")
|
|
143
|
+
sp.add_argument("--cov", default=None,
|
|
144
|
+
help="coverage target package (default: inferred)")
|
|
145
|
+
sp.add_argument("--suite-timeout", type=int, default=300,
|
|
146
|
+
help="full-suite budget in seconds (default 300)")
|
|
147
|
+
sp.add_argument("--test-timeout", type=int, default=60,
|
|
148
|
+
help="per-test pytest-timeout in seconds (default 60)")
|
|
149
|
+
|
|
150
|
+
sp = sub.add_parser("guard", help="commit-and-forget maintenance: watch "
|
|
151
|
+
"the suite, restore what breaks, refuse what is novel")
|
|
152
|
+
common(sp, need_file=False)
|
|
153
|
+
sp.add_argument("--observer", choices=["mechanical", "claude"],
|
|
154
|
+
default="mechanical")
|
|
155
|
+
sp.add_argument("--model", default="claude-opus-5")
|
|
156
|
+
sp.add_argument("--candidate-timeout", type=int, default=None,
|
|
157
|
+
help="per-candidate suite budget (default: --suite-timeout)")
|
|
158
|
+
sp.add_argument("--interval", type=int, default=None,
|
|
159
|
+
help="seconds between checks; omit for one pass (CI mode)")
|
|
160
|
+
sp.add_argument("--commit", action="store_true",
|
|
161
|
+
help="git-commit each restoration (only the repaired file)")
|
|
162
|
+
sp.set_defaults(fn=cmd_guard)
|
|
163
|
+
|
|
164
|
+
sp = sub.add_parser("repair", help="localise, observe, and repair one defect")
|
|
165
|
+
common(sp)
|
|
166
|
+
sp.add_argument("--observer", choices=["mechanical", "claude"],
|
|
167
|
+
default="mechanical")
|
|
168
|
+
sp.add_argument("--model", default="claude-opus-5")
|
|
169
|
+
sp.add_argument("--candidate-timeout", type=int, default=None,
|
|
170
|
+
help="per-candidate suite budget (default: --suite-timeout)")
|
|
171
|
+
sp.add_argument("--json", action="store_true")
|
|
172
|
+
sp.set_defaults(fn=cmd_repair)
|
|
173
|
+
|
|
174
|
+
sp = sub.add_parser("packet", help="print the lean observation packet")
|
|
175
|
+
common(sp)
|
|
176
|
+
sp.set_defaults(fn=cmd_packet)
|
|
177
|
+
|
|
178
|
+
sp = sub.add_parser("selfcheck", help="re-verify the shipped laws exhaustively")
|
|
179
|
+
sp.set_defaults(fn=cmd_selfcheck)
|
|
180
|
+
|
|
181
|
+
args = p.parse_args(argv)
|
|
182
|
+
return args.fn(args)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
raise SystemExit(main())
|
fluidfix/guard.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 devkancheti4-design
|
|
3
|
+
# Commercial licensing: see COMMERCIAL.md.
|
|
4
|
+
"""The guard: commit-and-forget maintenance.
|
|
5
|
+
|
|
6
|
+
green -> touch nothing, sleep
|
|
7
|
+
red -> find the fault file mechanically, localise, observe, route,
|
|
8
|
+
repair — the repo goes back to what it was meant to be
|
|
9
|
+
novel -> refuse LOUDLY (a machine-readable refusal report), tree
|
|
10
|
+
untouched; teaching the class once (register(), or a compiled
|
|
11
|
+
dictionary) makes its whole family free from then on
|
|
12
|
+
|
|
13
|
+
Fault-file discovery is mechanical: source files quoted in the failing
|
|
14
|
+
traceback (deepest frame preferred, test files excluded), falling back to the
|
|
15
|
+
files the failing test executed most, ranked by coverage. No model is needed
|
|
16
|
+
to find the file — only, optionally, to name the fault kind.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from .localize import build_packet
|
|
28
|
+
from .loop import RepairResult, repair
|
|
29
|
+
from .oracle import Oracle
|
|
30
|
+
|
|
31
|
+
__all__ = ["GuardReport", "find_candidate_files", "guard_once"]
|
|
32
|
+
|
|
33
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class GuardReport:
|
|
38
|
+
status: str # "green" | "repaired" | "refused"
|
|
39
|
+
file: str | None = None
|
|
40
|
+
result: RepairResult | None = None
|
|
41
|
+
candidates: list[str] = field(default_factory=list)
|
|
42
|
+
seconds: float = 0.0
|
|
43
|
+
|
|
44
|
+
def summary(self) -> str:
|
|
45
|
+
if self.status == "green":
|
|
46
|
+
return "suite green — nothing to do"
|
|
47
|
+
if self.status == "repaired":
|
|
48
|
+
return f"{self.file}: {self.result.summary()}"
|
|
49
|
+
return ("REFUSED: fault is outside the taught vocabulary "
|
|
50
|
+
f"(candidate files tried: {', '.join(self.candidates) or 'none found'}). "
|
|
51
|
+
"Teach the class once — register() an observation + transform — "
|
|
52
|
+
"and its whole family becomes free.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_test_path(rel: str) -> bool:
|
|
56
|
+
parts = rel.replace("\\", "/").split("/")
|
|
57
|
+
base = parts[-1]
|
|
58
|
+
return (base.startswith("test_") or base.endswith("_test.py")
|
|
59
|
+
or "tests" in parts[:-1] or base == "conftest.py")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_candidate_files(oracle: Oracle, failing_output: str,
|
|
63
|
+
limit: int = 3) -> list[str]:
|
|
64
|
+
"""Project source files implicated by the failure, most suspect first."""
|
|
65
|
+
clean = _ANSI.sub("", failing_output)
|
|
66
|
+
ordered: list[str] = []
|
|
67
|
+
for m in re.finditer(r"([\w./\\-]+\.py)[\":,]", clean):
|
|
68
|
+
p = m.group(1).replace("\\", "/")
|
|
69
|
+
full = p if os.path.isabs(p) else os.path.join(oracle.root, p)
|
|
70
|
+
full = os.path.normpath(full)
|
|
71
|
+
if not full.startswith(oracle.root + os.sep) or not os.path.isfile(full):
|
|
72
|
+
continue
|
|
73
|
+
rel = os.path.relpath(full, oracle.root).replace("\\", "/")
|
|
74
|
+
if _is_test_path(rel):
|
|
75
|
+
continue
|
|
76
|
+
# deepest (latest) traceback frame is closest to the fault
|
|
77
|
+
if rel in ordered:
|
|
78
|
+
ordered.remove(rel)
|
|
79
|
+
ordered.append(rel)
|
|
80
|
+
ordered.reverse()
|
|
81
|
+
if ordered:
|
|
82
|
+
return ordered[:limit]
|
|
83
|
+
# no source frames (pure assertion failure): rank by the failing test's
|
|
84
|
+
# own coverage — run recorded by the caller's failing_output(), so --lf
|
|
85
|
+
# re-runs exactly it. No -x: exit-first suppresses the JSON report.
|
|
86
|
+
cov_json = os.path.join(oracle.root, "_fluidfix_guard_cov.json")
|
|
87
|
+
oracle.run(["--lf", "--tb=no", "--cov=.",
|
|
88
|
+
f"--cov-report=json:{cov_json}"], cache=True)
|
|
89
|
+
ranked: list[tuple[int, str]] = []
|
|
90
|
+
if os.path.exists(cov_json):
|
|
91
|
+
try:
|
|
92
|
+
cov = json.load(open(cov_json))
|
|
93
|
+
for f, data in cov.get("files", {}).items():
|
|
94
|
+
rel = f.replace("\\", "/")
|
|
95
|
+
if _is_test_path(rel) or not rel.endswith(".py"):
|
|
96
|
+
continue
|
|
97
|
+
ranked.append((len(data.get("executed_lines", [])), rel))
|
|
98
|
+
finally:
|
|
99
|
+
os.remove(cov_json)
|
|
100
|
+
ranked.sort(reverse=True)
|
|
101
|
+
return [rel for _, rel in ranked[:limit]]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def guard_once(oracle: Oracle, observer, files: list[str] | None = None,
|
|
105
|
+
coverage_target: str | None = None,
|
|
106
|
+
candidate_timeout: int | None = None) -> GuardReport:
|
|
107
|
+
t0 = time.time()
|
|
108
|
+
fails, out = oracle.failing_output()
|
|
109
|
+
if not fails:
|
|
110
|
+
return GuardReport(status="green", seconds=time.time() - t0)
|
|
111
|
+
candidates = files or find_candidate_files(oracle, out)
|
|
112
|
+
for rel in candidates:
|
|
113
|
+
packet = build_packet(oracle, rel, coverage_target=coverage_target)
|
|
114
|
+
if packet is None:
|
|
115
|
+
continue
|
|
116
|
+
observations = observer.observe([packet])[0]
|
|
117
|
+
result = repair(oracle, rel, observations,
|
|
118
|
+
candidate_timeout=candidate_timeout)
|
|
119
|
+
if result.repaired:
|
|
120
|
+
return GuardReport(status="repaired", file=rel, result=result,
|
|
121
|
+
candidates=candidates,
|
|
122
|
+
seconds=time.time() - t0)
|
|
123
|
+
return GuardReport(status="refused", candidates=candidates,
|
|
124
|
+
seconds=time.time() - t0)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def commit_repair(root: str, report: GuardReport) -> bool:
|
|
128
|
+
"""Opt-in: commit a successful restoration. Only the repaired file."""
|
|
129
|
+
if report.status != "repaired":
|
|
130
|
+
return False
|
|
131
|
+
r = report.result
|
|
132
|
+
msg = (f"fluidfix: restore {report.file}:{r.lineno}\n\n"
|
|
133
|
+
f"- {r.old_line.strip()}\n+ {r.new_line.strip()}\n\n"
|
|
134
|
+
f"Routed by the fluidfix kernel ({r.reason}); accepted by the "
|
|
135
|
+
f"project's own suite in {r.suite_runs} runs.")
|
|
136
|
+
try:
|
|
137
|
+
subprocess.run(["git", "-C", root, "add", "--", report.file],
|
|
138
|
+
check=True, capture_output=True, timeout=30)
|
|
139
|
+
subprocess.run(["git", "-C", root, "commit", "-m", msg],
|
|
140
|
+
check=True, capture_output=True, timeout=30)
|
|
141
|
+
return True
|
|
142
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
|
|
143
|
+
FileNotFoundError):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def write_refusal(root: str, report: GuardReport) -> str:
|
|
148
|
+
"""A machine-readable teach-me signal, written where CI can pick it up."""
|
|
149
|
+
d = os.path.join(root, ".fluidfix")
|
|
150
|
+
os.makedirs(d, exist_ok=True)
|
|
151
|
+
path = os.path.join(d, "last_refusal.json")
|
|
152
|
+
json.dump({"status": report.status, "candidates": report.candidates,
|
|
153
|
+
"seconds": report.seconds,
|
|
154
|
+
"hint": "fault class is outside the taught vocabulary; "
|
|
155
|
+
"register() it once and its family becomes free"},
|
|
156
|
+
open(path, "w"), indent=1)
|
|
157
|
+
return path
|
fluidfix/lanes.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 devkancheti4-design
|
|
3
|
+
# Commercial licensing: see COMMERCIAL.md.
|
|
4
|
+
"""Loop discipline — vendored unchanged from fluid-router2
|
|
5
|
+
(https://github.com/devkancheti4-design/fluid-router2).
|
|
6
|
+
|
|
7
|
+
Three machine-authored expressions drive every repair loop. `ADVANCE` strictly
|
|
8
|
+
reduces a live mask, which is what makes the loop a solver rather than a
|
|
9
|
+
classifier: it terminates on all 256 mask states (verified exhaustively in
|
|
10
|
+
fluid-router2's `verify.c`, re-verified by this package's test suite).
|
|
11
|
+
|
|
12
|
+
EMIT which fault to handle next (the lowest live bit)
|
|
13
|
+
ADVANCE clear it and keep going
|
|
14
|
+
HALT nothing left — 1 iff the mask is empty
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__all__ = ["EMIT", "ADVANCE", "HALT", "mask_of", "kind_of"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def EMIT(m: int) -> int:
|
|
21
|
+
return m & (-m)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ADVANCE(m: int) -> int:
|
|
25
|
+
return m - (m & (-m))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def HALT(m: int) -> int:
|
|
29
|
+
# 32-bit semantics of ((m - (m - 1)) + ((-m) >> 31)): 1 iff m == 0.
|
|
30
|
+
if m == 0:
|
|
31
|
+
return 1
|
|
32
|
+
return (m - (m - 1)) + (-1 if -m < 0 else 0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mask_of(kinds) -> int:
|
|
36
|
+
"""A live mask with one bit per fault kind."""
|
|
37
|
+
m = 0
|
|
38
|
+
for k in kinds:
|
|
39
|
+
m |= 1 << k
|
|
40
|
+
return m
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def kind_of(emitted: int) -> int:
|
|
44
|
+
"""The kind number of an EMITted bit."""
|
|
45
|
+
return emitted.bit_length() - 1
|