@giamme/forge 0.0.0-stage → 0.8.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/CHANGELOG.md +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""forge-ui: a small read-only local UI for forge runs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__version__ = '0.4.0'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_id():
|
|
11
|
+
"""sha256 hex of this package's *.py files: sorted by name, each name then its bytes.
|
|
12
|
+
|
|
13
|
+
Static files are not part of the id. The server re-reads them on every request.
|
|
14
|
+
"""
|
|
15
|
+
digest = hashlib.sha256()
|
|
16
|
+
root = Path(__file__).resolve().parent
|
|
17
|
+
for path in sorted(root.glob('*.py'), key=lambda item: item.name):
|
|
18
|
+
digest.update(path.name.encode('utf-8'))
|
|
19
|
+
digest.update(path.read_bytes())
|
|
20
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Run the forge-ui server: python3 -m forge_ui."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
from forge_ui import __version__, reader
|
|
14
|
+
from forge_ui.server import make_server
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
HOST = '127.0.0.1' # deliberately not an option: the UI shows prompts and reviews from private repos
|
|
18
|
+
DEFAULT_PORT = 8765
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser():
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog='python3 -m forge_ui',
|
|
24
|
+
description='A small, read-only local UI for forge runs. Listens on 127.0.0.1 only.')
|
|
25
|
+
parser.add_argument('--plan', action='append', default=[], metavar='DIR',
|
|
26
|
+
help='a plan dir to show (repeatable)')
|
|
27
|
+
parser.add_argument('--root', action='append', default=[], metavar='DIR',
|
|
28
|
+
help='show every plan dir at depth 0-2 under DIR (repeatable)')
|
|
29
|
+
parser.add_argument('--no-registry', action='store_true',
|
|
30
|
+
help="do not read forge's run registry")
|
|
31
|
+
parser.add_argument('--registry-dir', metavar='DIR',
|
|
32
|
+
help='read the run registry from DIR instead of ~/.config/forge/runs')
|
|
33
|
+
parser.add_argument('--port', type=int, default=DEFAULT_PORT,
|
|
34
|
+
help=f'port to listen on (default {DEFAULT_PORT}; 0 picks a free one)')
|
|
35
|
+
parser.add_argument('--idle-exit', type=int, default=0, metavar='SECONDS',
|
|
36
|
+
help='exit after SECONDS with no request (default 0, which never exits)')
|
|
37
|
+
parser.add_argument('--quiet', action='store_true', help='do not log requests')
|
|
38
|
+
parser.add_argument('--open', action='store_true', help='open the page in the browser once started')
|
|
39
|
+
parser.add_argument('--version', action='version', version=f'forge-ui {__version__}')
|
|
40
|
+
return parser
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _message(level, text):
|
|
44
|
+
print(f'forge-ui: {level}: {text}', file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _watch_idle(server, seconds, stop):
|
|
48
|
+
# About once a second. shutdown() deadlocks unless serve_forever() is running in
|
|
49
|
+
# another thread, so when main leaves that loop another way (Ctrl-C) it sets `stop`
|
|
50
|
+
# and this thread must not call shutdown().
|
|
51
|
+
while not stop.wait(1):
|
|
52
|
+
# The decision and the shutdown happen under the lock a request takes to be counted, so no request
|
|
53
|
+
# can be admitted between "idle" and the stop: one that arrives meanwhile waits for the lock and
|
|
54
|
+
# then reaches a server that is already stopping, like one that arrives just after the exit.
|
|
55
|
+
# shutdown() only waits for serve_forever() to leave its loop, which never needs this lock.
|
|
56
|
+
with server.busy_lock:
|
|
57
|
+
if server.busy == 0 and time.monotonic() - server.last_request >= seconds:
|
|
58
|
+
server.shutdown()
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _port_taken(port):
|
|
63
|
+
# Binding is not enough: SO_REUSEADDR lets a 127.0.0.1 bind succeed on macOS while another process
|
|
64
|
+
# listens on 0.0.0.0 or [::] (dual-stack) at the same port, and the requests then go to whichever wins.
|
|
65
|
+
# A probe connect reaches any of those listeners, so it is the check that matters.
|
|
66
|
+
if port == 0:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
socket.create_connection((HOST, port), timeout=1).close()
|
|
70
|
+
except socket.timeout:
|
|
71
|
+
return True # something accepted the SYN but does not answer: treat as taken
|
|
72
|
+
except OSError:
|
|
73
|
+
return False # refused or unreachable: nothing is listening there
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv=None):
|
|
78
|
+
parser = _parser()
|
|
79
|
+
try:
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
except SystemExit as exc: # --version, --help and usage errors leave through argparse
|
|
82
|
+
return exc.code or 0
|
|
83
|
+
if not 0 <= args.port <= 65535:
|
|
84
|
+
_message('error', f'port {args.port} is out of range (0-65535)')
|
|
85
|
+
return 2
|
|
86
|
+
if args.idle_exit < 0:
|
|
87
|
+
_message('error', f'--idle-exit {args.idle_exit} is out of range (0 or more seconds)')
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
for flag, paths in (('--plan', args.plan), ('--root', args.root)):
|
|
91
|
+
for path in paths:
|
|
92
|
+
if not os.path.exists(path):
|
|
93
|
+
_message('warning', f'{flag} {path}: no such file or directory')
|
|
94
|
+
|
|
95
|
+
in_use = f'port {args.port} is already in use; try --port with another number'
|
|
96
|
+
if _port_taken(args.port):
|
|
97
|
+
_message('error', in_use)
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
catalog = reader.Catalog(plans=args.plan, roots=args.root, registry=not args.no_registry,
|
|
101
|
+
registry_dir=args.registry_dir)
|
|
102
|
+
try:
|
|
103
|
+
server = make_server(catalog, HOST, args.port, quiet=args.quiet)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
if exc.errno == errno.EADDRINUSE:
|
|
106
|
+
_message('error', in_use)
|
|
107
|
+
else:
|
|
108
|
+
_message('error', f'cannot listen on {HOST}:{args.port}: {exc.strerror or exc}')
|
|
109
|
+
return 2
|
|
110
|
+
|
|
111
|
+
url = f'http://{HOST}:{server.server_address[1]}/'
|
|
112
|
+
stop = threading.Event()
|
|
113
|
+
watcher = None
|
|
114
|
+
try:
|
|
115
|
+
print(f'forge-ui on {url} ({len(catalog.runs())} runs)', flush=True)
|
|
116
|
+
if args.open:
|
|
117
|
+
webbrowser.open(url)
|
|
118
|
+
# The idle window starts once we are accepting requests, after discovery and this line.
|
|
119
|
+
server.last_request = time.monotonic()
|
|
120
|
+
if args.idle_exit > 0:
|
|
121
|
+
watcher = threading.Thread(target=_watch_idle, args=(server, args.idle_exit, stop), daemon=True)
|
|
122
|
+
watcher.start()
|
|
123
|
+
server.serve_forever()
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
pass
|
|
126
|
+
finally:
|
|
127
|
+
stop.set()
|
|
128
|
+
server.server_close()
|
|
129
|
+
if watcher is not None:
|
|
130
|
+
watcher.join(timeout=5)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == '__main__':
|
|
135
|
+
sys.exit(main())
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Unified diffs as `git diff --binary` writes them: parsed, cut out of a review prompt, and
|
|
2
|
+
findings pinned to the file and line they name. Reads are bounded and stay inside the run dir."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from forge_ui import executions
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
14
|
+
_OPEN = os.O_RDONLY | getattr(os, 'O_NONBLOCK', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
15
|
+
_FENCE = re.compile(r'^```diff\r?$', re.MULTILINE)
|
|
16
|
+
_CLOSE = re.compile(r'^```\r?$', re.MULTILINE)
|
|
17
|
+
_HUNK = re.compile(r'@@ -([0-9]+)(?:,([0-9]+))? \+([0-9]+)(?:,([0-9]+))? @@ ?(.*)')
|
|
18
|
+
_OCTAL = re.compile(r'[0-7]{1,3}')
|
|
19
|
+
_ESCAPES = {'a': '\a', 'b': '\b', 't': '\t', 'n': '\n', 'v': '\v', 'f': '\f', 'r': '\r', '"': '"', '\\': '\\'}
|
|
20
|
+
# git's own prefix pairs (a/ b/, mnemonic c/ i/ w/ o/, --no-index 1/ 2/) always differ.
|
|
21
|
+
_PREFIX = re.compile(r'[a-z0-9]/')
|
|
22
|
+
_PIN = re.compile(r'(?<![\w/.-])((?:[\w.@+-]+/)*[\w.@+-]+):([0-9]+)(?:[-–:]([0-9]+))?')
|
|
23
|
+
_AT = re.compile(r'(.+):([0-9]+)')
|
|
24
|
+
_LINE = re.compile(r'[Ll]ines? ([0-9]+)')
|
|
25
|
+
# A parsed diff is big: it gets a small cache of its own.
|
|
26
|
+
_DIFFS = executions._SigCache(64)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- parsing -------------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _unquote(value):
|
|
32
|
+
"""A path as git writes it, C-quoted ("a/caf\\303\\251") when it has unusual bytes."""
|
|
33
|
+
if len(value) < 2 or value[0] != '"' or value[-1] != '"':
|
|
34
|
+
return value
|
|
35
|
+
body, out, i = value[1:-1], bytearray(), 0
|
|
36
|
+
while i < len(body):
|
|
37
|
+
char = body[i]
|
|
38
|
+
if char == '\\' and i + 1 < len(body):
|
|
39
|
+
digits = _OCTAL.match(body, i + 1)
|
|
40
|
+
if digits:
|
|
41
|
+
out.append(int(digits.group(0), 8) & 0xFF)
|
|
42
|
+
i = digits.end()
|
|
43
|
+
continue
|
|
44
|
+
char, i = _ESCAPES.get(body[i + 1], body[i + 1]), i + 1
|
|
45
|
+
out += char.encode('utf-8', 'surrogatepass')
|
|
46
|
+
i += 1
|
|
47
|
+
return out.decode('utf-8', 'replace')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _quoted(text):
|
|
51
|
+
"""(the C-quoted string `text` starts with, what follows it), or (None, text)."""
|
|
52
|
+
i = 1
|
|
53
|
+
while i < len(text):
|
|
54
|
+
if text[i] == '\\':
|
|
55
|
+
i += 2
|
|
56
|
+
elif text[i] == '"':
|
|
57
|
+
return text[:i + 1], text[i + 1:]
|
|
58
|
+
else:
|
|
59
|
+
i += 1
|
|
60
|
+
return None, text
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _git_paths(rest):
|
|
64
|
+
"""The two paths of a `diff --git` line, prefixes kept, or (None, None)."""
|
|
65
|
+
if rest.startswith('"'):
|
|
66
|
+
first, tail = _quoted(rest)
|
|
67
|
+
if first is None or not tail.startswith(' '):
|
|
68
|
+
return None, None
|
|
69
|
+
return _unquote(first), _unquote(tail[1:])
|
|
70
|
+
if rest.endswith('"'):
|
|
71
|
+
at = rest.rfind(' "')
|
|
72
|
+
if at > 0:
|
|
73
|
+
return rest[:at], _unquote(rest[at + 1:])
|
|
74
|
+
# Unquoted paths may hold spaces; both sides naming one path is the common case.
|
|
75
|
+
half = (len(rest) - 1) // 2
|
|
76
|
+
if len(rest) % 2 and rest[half] == ' ' and rest[2:half] == rest[half + 3:]:
|
|
77
|
+
return rest[:half], rest[half + 1:]
|
|
78
|
+
at = rest.find(' b/')
|
|
79
|
+
return (rest[:at], rest[at + 1:]) if at > 0 else (None, None)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _header_path(value):
|
|
83
|
+
if value.startswith('"'):
|
|
84
|
+
return _unquote(_quoted(value)[0] or value)
|
|
85
|
+
return value.split('\t', 1)[0] # a plain diff may follow the name with a tab and a timestamp
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _strip(value, prefix):
|
|
89
|
+
if value is None or value == '/dev/null':
|
|
90
|
+
return None
|
|
91
|
+
return value[len(prefix):] if prefix and value.startswith(prefix) else value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _new_file(pair=(None, None)):
|
|
95
|
+
return dict(_pair=pair, _old=None, _new=None, _minus=False, _plus=False, _body=False, _added=False,
|
|
96
|
+
_deleted=False, _from=None, _to=None, _how=None, binary=False, additions=0, deletions=0,
|
|
97
|
+
truncated=False, hunks=[])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _finish(item):
|
|
101
|
+
old, new = item['_pair']
|
|
102
|
+
if old is not None:
|
|
103
|
+
heads = old[:2], new[:2]
|
|
104
|
+
prefixes = heads if all(_PREFIX.fullmatch(head) for head in heads) and heads[0] != heads[1] else ('', '')
|
|
105
|
+
old, new = item['_old'] if item['_minus'] else old, item['_new'] if item['_plus'] else new
|
|
106
|
+
else:
|
|
107
|
+
old, new = item['_old'], item['_new']
|
|
108
|
+
prefixes = 'a/' if (old or '').startswith('a/') else '', 'b/' if (new or '').startswith('b/') else ''
|
|
109
|
+
old, new = _strip(old, prefixes[0]), _strip(new, prefixes[1])
|
|
110
|
+
old, new = item['_from'] or old, item['_to'] or new
|
|
111
|
+
if item['_how']:
|
|
112
|
+
status = item['_how']
|
|
113
|
+
elif item['_added'] or (item['_minus'] and old is None):
|
|
114
|
+
status, old = 'added', None
|
|
115
|
+
elif item['_deleted'] or (item['_plus'] and new is None):
|
|
116
|
+
status, new = 'deleted', None
|
|
117
|
+
else:
|
|
118
|
+
status = 'modified'
|
|
119
|
+
if old is None and new is None:
|
|
120
|
+
return None
|
|
121
|
+
return dict(old=old, new=new, status=status, binary=item['binary'], additions=item['additions'],
|
|
122
|
+
deletions=item['deletions'], truncated=item['truncated'], hunks=item['hunks'])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse(text, max_files, max_lines, max_line, keep=True):
|
|
126
|
+
"""(files, cut): cut when files past `max_files` were dropped."""
|
|
127
|
+
files, budget = [], max_lines
|
|
128
|
+
item = hunk = None
|
|
129
|
+
due_old = due_new = 0
|
|
130
|
+
skipping = False # inside a GIT binary patch, or a combined diff forge never writes
|
|
131
|
+
lines = (text or '').split('\n')
|
|
132
|
+
if lines and lines[-1] == '':
|
|
133
|
+
lines.pop()
|
|
134
|
+
|
|
135
|
+
def close():
|
|
136
|
+
if item is not None:
|
|
137
|
+
done = _finish(item)
|
|
138
|
+
if done is not None:
|
|
139
|
+
files.append(done)
|
|
140
|
+
|
|
141
|
+
def store(raw):
|
|
142
|
+
nonlocal budget
|
|
143
|
+
if hunk is None:
|
|
144
|
+
return
|
|
145
|
+
if budget <= 0:
|
|
146
|
+
item['truncated'] = True
|
|
147
|
+
return
|
|
148
|
+
if len(raw) > max_line:
|
|
149
|
+
raw = raw[:max_line] + '…'
|
|
150
|
+
item['truncated'] = True
|
|
151
|
+
hunk['lines'].append(raw)
|
|
152
|
+
budget -= 1
|
|
153
|
+
|
|
154
|
+
i = 0
|
|
155
|
+
while i < len(lines):
|
|
156
|
+
line = lines[i]
|
|
157
|
+
i += 1
|
|
158
|
+
if line.endswith('\r'):
|
|
159
|
+
line = line[:-1]
|
|
160
|
+
if due_old > 0 or due_new > 0:
|
|
161
|
+
mark = line[:1] or ' '
|
|
162
|
+
if mark in ' -+\\':
|
|
163
|
+
if mark in ' -':
|
|
164
|
+
due_old -= 1
|
|
165
|
+
if mark in ' +':
|
|
166
|
+
due_new -= 1
|
|
167
|
+
if mark == '+':
|
|
168
|
+
item['additions'] += 1
|
|
169
|
+
elif mark == '-':
|
|
170
|
+
item['deletions'] += 1
|
|
171
|
+
store(line or ' ')
|
|
172
|
+
continue
|
|
173
|
+
due_old = due_new = 0 # a short hunk: this line is a header again
|
|
174
|
+
if line.startswith('diff --git ') or line.startswith(('diff --cc ', 'diff --combined ')):
|
|
175
|
+
close()
|
|
176
|
+
item = hunk = None
|
|
177
|
+
if len(files) >= max_files:
|
|
178
|
+
return files, True
|
|
179
|
+
skipping = not line.startswith('diff --git ')
|
|
180
|
+
if not skipping:
|
|
181
|
+
item = _new_file(_git_paths(line[len('diff --git '):]))
|
|
182
|
+
continue
|
|
183
|
+
if skipping:
|
|
184
|
+
continue
|
|
185
|
+
if line.startswith('--- ') and i < len(lines) and lines[i].startswith('+++ ') and (
|
|
186
|
+
item is None or item['_body'] or item['_minus']):
|
|
187
|
+
close() # a plain unified diff: its files start at ---
|
|
188
|
+
hunk = None
|
|
189
|
+
if len(files) >= max_files:
|
|
190
|
+
return files, True
|
|
191
|
+
item = _new_file()
|
|
192
|
+
if item is None:
|
|
193
|
+
continue
|
|
194
|
+
match = _HUNK.match(line) if line.startswith('@@ ') else None
|
|
195
|
+
if match:
|
|
196
|
+
old_start, old_lines, new_start, new_lines, section = match.groups()
|
|
197
|
+
due_old = 1 if old_lines is None else int(old_lines)
|
|
198
|
+
due_new = 1 if new_lines is None else int(new_lines)
|
|
199
|
+
item['_body'] = True
|
|
200
|
+
hunk = None
|
|
201
|
+
if keep and budget > 0:
|
|
202
|
+
hunk = dict(old_start=int(old_start), old_lines=due_old, new_start=int(new_start), new_lines=due_new,
|
|
203
|
+
section=section or None, lines=[])
|
|
204
|
+
item['hunks'].append(hunk)
|
|
205
|
+
elif keep:
|
|
206
|
+
item['truncated'] = True
|
|
207
|
+
continue
|
|
208
|
+
if line.startswith('\\'):
|
|
209
|
+
store(line) # `` follows the hunk's last counted line
|
|
210
|
+
continue
|
|
211
|
+
if item['_body']:
|
|
212
|
+
continue
|
|
213
|
+
if line.startswith('--- ') and not item['_minus']:
|
|
214
|
+
item['_old'], item['_minus'] = _header_path(line[4:]), True
|
|
215
|
+
elif line.startswith('+++ ') and not item['_plus']:
|
|
216
|
+
item['_new'], item['_plus'] = _header_path(line[4:]), True
|
|
217
|
+
elif line.startswith('new file mode '):
|
|
218
|
+
item['_added'] = True
|
|
219
|
+
elif line.startswith('deleted file mode '):
|
|
220
|
+
item['_deleted'] = True
|
|
221
|
+
elif line.startswith(('rename from ', 'copy from ')):
|
|
222
|
+
item['_from'] = _unquote(line.split(' ', 2)[2])
|
|
223
|
+
item['_how'] = 'renamed' if line.startswith('rename') else 'copied'
|
|
224
|
+
elif line.startswith(('rename to ', 'copy to ')):
|
|
225
|
+
item['_to'] = _unquote(line.split(' ', 2)[2])
|
|
226
|
+
item['_how'] = 'renamed' if line.startswith('rename') else 'copied'
|
|
227
|
+
elif line == 'GIT binary patch':
|
|
228
|
+
item['binary'] = skipping = True
|
|
229
|
+
elif line.startswith('Binary files ') and line.endswith(' differ'):
|
|
230
|
+
item['binary'] = True
|
|
231
|
+
item['_added'] = item['_added'] or line.startswith('Binary files /dev/null and ')
|
|
232
|
+
item['_deleted'] = item['_deleted'] or line.endswith(' and /dev/null differ')
|
|
233
|
+
close()
|
|
234
|
+
return files, False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def touched(text, limit=2000):
|
|
238
|
+
"""Paths a diff changes, from its headers: the new path, the old one for a deleted file."""
|
|
239
|
+
return list(dict.fromkeys(item['new'] or item['old'] for item in _parse(text, limit, 0, 0, keep=False)[0]))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def fenced_diff(text):
|
|
243
|
+
"""(body, terminated) of the last ```diff fence of a prompt; (None, False) when it has none."""
|
|
244
|
+
last = None
|
|
245
|
+
for last in _FENCE.finditer(text or ''):
|
|
246
|
+
pass
|
|
247
|
+
if last is None:
|
|
248
|
+
return None, False
|
|
249
|
+
start = min(last.end() + 1, len(text))
|
|
250
|
+
close = _CLOSE.search(text, start)
|
|
251
|
+
return (text[start:close.start()], True) if close else (text[start:], False)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --- pinning --------------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _variants(path):
|
|
257
|
+
path = path.replace('\\', '/')
|
|
258
|
+
while path.startswith('./'):
|
|
259
|
+
path = path[2:]
|
|
260
|
+
return [path, path[2:]] if path[:2] in ('a/', 'b/') and len(path) > 2 else [path]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _match(path, files):
|
|
264
|
+
"""(file index, side) of the diff file `path` names, 'ambiguous', or None."""
|
|
265
|
+
wanted = [want for want in _variants(path) if want]
|
|
266
|
+
targets = []
|
|
267
|
+
for index, item in enumerate(files):
|
|
268
|
+
if item['new'] is not None:
|
|
269
|
+
targets.append((item['new'], index, 'new'))
|
|
270
|
+
if item['old'] is not None and item['status'] in ('deleted', 'renamed'):
|
|
271
|
+
targets.append((item['old'], index, 'old'))
|
|
272
|
+
for exact in (True, False):
|
|
273
|
+
hits = [(index, side) for name, index, side in targets for want in wanted
|
|
274
|
+
if (name == want if exact else name.endswith('/' + want) or want.endswith('/' + name))]
|
|
275
|
+
if hits:
|
|
276
|
+
return hits[0] if len({index for index, _ in hits}) == 1 else 'ambiguous'
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _place(files, target, line):
|
|
281
|
+
index, side = target
|
|
282
|
+
item = files[index]
|
|
283
|
+
own = '+' if side == 'new' else '-'
|
|
284
|
+
for number, hunk in enumerate(item['hunks']):
|
|
285
|
+
start, count = (hunk['new_start'], hunk['new_lines']) if side == 'new' else (hunk['old_start'], hunk['old_lines'])
|
|
286
|
+
if start <= line < start + count:
|
|
287
|
+
at = None
|
|
288
|
+
for position, raw in enumerate(hunk['lines']):
|
|
289
|
+
if raw[:1] in (' ', own):
|
|
290
|
+
if start == line:
|
|
291
|
+
at = position
|
|
292
|
+
break
|
|
293
|
+
start += 1
|
|
294
|
+
return dict(file=index, path=item[side], line=line, side=side, hunk=number, index=at), None
|
|
295
|
+
outside = dict(file=index, path=item[side], line=line, side=side, hunk=None, index=None)
|
|
296
|
+
return outside, 'line outside the changed hunks'
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _pin(finding, files):
|
|
300
|
+
location = finding.get('location') or ''
|
|
301
|
+
at = _AT.fullmatch(location)
|
|
302
|
+
if at:
|
|
303
|
+
target = _match(at.group(1), files)
|
|
304
|
+
if target is None:
|
|
305
|
+
return None, 'file not in this diff'
|
|
306
|
+
if target == 'ambiguous':
|
|
307
|
+
return None, 'ambiguous path'
|
|
308
|
+
return _place(files, target, int(at.group(2)))
|
|
309
|
+
# The contract's location misses names without an extension (Makefile:3): accept one
|
|
310
|
+
# only when it names a file of this diff.
|
|
311
|
+
first = (finding.get('text') or finding.get('summary') or '').split('\n', 1)[0]
|
|
312
|
+
ambiguous = False
|
|
313
|
+
for found in _PIN.finditer(first):
|
|
314
|
+
target = _match(found.group(1), files)
|
|
315
|
+
if target == 'ambiguous':
|
|
316
|
+
ambiguous = True
|
|
317
|
+
elif target is not None:
|
|
318
|
+
return _place(files, target, int(found.group(2)))
|
|
319
|
+
if ambiguous:
|
|
320
|
+
return None, 'ambiguous path'
|
|
321
|
+
words = _LINE.fullmatch(location)
|
|
322
|
+
if words and len(files) == 1:
|
|
323
|
+
return _place(files, (0, 'old' if files[0]['status'] == 'deleted' else 'new'), int(words.group(1)))
|
|
324
|
+
return None, 'no location'
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def pin(items, files):
|
|
328
|
+
"""Each Finding of `items` with `pin` (where in `files` it points, or null) and `off_diff` (why not)."""
|
|
329
|
+
result = []
|
|
330
|
+
for item in items:
|
|
331
|
+
where, why = _pin(item, files)
|
|
332
|
+
result.append(dict(item, pin=where, off_diff=why))
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --- bounded, cached reads ------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def read(reader, rel, limit=MAX_BYTES):
|
|
339
|
+
"""(text, fstat, cut) of the first `limit` bytes of a regular file under the run dir, or None.
|
|
340
|
+
|
|
341
|
+
A cut read ends at its last whole line.
|
|
342
|
+
"""
|
|
343
|
+
path = reader.path / rel
|
|
344
|
+
try:
|
|
345
|
+
real = os.path.realpath(path)
|
|
346
|
+
if not executions.inside(reader.path, real):
|
|
347
|
+
if os.path.lexists(path):
|
|
348
|
+
reader.warn(rel, 'outside the run dir')
|
|
349
|
+
return None
|
|
350
|
+
# Do not open special files (a FIFO could otherwise block forever).
|
|
351
|
+
if not stat.S_ISREG(os.stat(real).st_mode):
|
|
352
|
+
reader.warn(rel, 'not a regular file')
|
|
353
|
+
return None
|
|
354
|
+
fd = os.open(real, _OPEN)
|
|
355
|
+
except FileNotFoundError:
|
|
356
|
+
if os.path.lexists(path):
|
|
357
|
+
reader.warn(rel, 'dangling symlink')
|
|
358
|
+
return None
|
|
359
|
+
except (OSError, ValueError) as exc:
|
|
360
|
+
reader.warn(rel, f'cannot read ({type(exc).__name__})')
|
|
361
|
+
return None
|
|
362
|
+
try:
|
|
363
|
+
info = os.fstat(fd)
|
|
364
|
+
if not stat.S_ISREG(info.st_mode):
|
|
365
|
+
reader.warn(rel, 'not a regular file')
|
|
366
|
+
return None
|
|
367
|
+
reader.track(info)
|
|
368
|
+
chunks, left = [], limit
|
|
369
|
+
while left > 0:
|
|
370
|
+
chunk = os.read(fd, min(left, 1 << 20))
|
|
371
|
+
if not chunk:
|
|
372
|
+
break
|
|
373
|
+
chunks.append(chunk)
|
|
374
|
+
left -= len(chunk)
|
|
375
|
+
raw = b''.join(chunks)
|
|
376
|
+
except OSError as exc:
|
|
377
|
+
reader.warn(rel, f'cannot read ({type(exc).__name__})')
|
|
378
|
+
return None
|
|
379
|
+
finally:
|
|
380
|
+
os.close(fd)
|
|
381
|
+
cut = info.st_size > len(raw)
|
|
382
|
+
if cut and b'\n' in raw:
|
|
383
|
+
raw = raw[:raw.rindex(b'\n') + 1]
|
|
384
|
+
return reader._decode(rel, raw)[0], info, cut
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cached(reader, rel, kind, parse, cache=None, limit=MAX_BYTES):
|
|
388
|
+
"""parse(text, cut, size) of a run file, kept by its signature as executions._cached keeps
|
|
389
|
+
its parses; a hit replays the warnings the parse gave. None when the file cannot be read."""
|
|
390
|
+
cache = executions._CACHE if cache is None else cache
|
|
391
|
+
path = reader.path / rel
|
|
392
|
+
try:
|
|
393
|
+
real = os.path.realpath(path)
|
|
394
|
+
info = os.stat(real) if executions.inside(reader.path, real) else None
|
|
395
|
+
except (OSError, ValueError):
|
|
396
|
+
info = None
|
|
397
|
+
if info is not None:
|
|
398
|
+
key, sig = (kind, real), executions._signature(info)
|
|
399
|
+
hit = cache.get(key, sig)
|
|
400
|
+
if hit is not None:
|
|
401
|
+
reader.track(info)
|
|
402
|
+
for reason in hit[1]:
|
|
403
|
+
reader.warn(rel, reason)
|
|
404
|
+
return hit[0]
|
|
405
|
+
mark = len(reader.warnings)
|
|
406
|
+
got = read(reader, rel, limit)
|
|
407
|
+
if got is None:
|
|
408
|
+
return None
|
|
409
|
+
text, info, cut = got
|
|
410
|
+
value = parse(text, cut, info.st_size)
|
|
411
|
+
new, prefix = reader.warnings[mark:], rel + ': '
|
|
412
|
+
if all(item.startswith(prefix) for item in new) and abs(time.time() - info.st_mtime) > executions._SETTLE_S:
|
|
413
|
+
try:
|
|
414
|
+
if executions._signature(os.stat(real)) == executions._signature(info):
|
|
415
|
+
cache.put((kind, real), executions._signature(info), (value, [item[len(prefix):] for item in new]))
|
|
416
|
+
except (OSError, ValueError):
|
|
417
|
+
pass
|
|
418
|
+
return value
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parsed(text, cut, size, terminated=True):
|
|
422
|
+
"""{bytes, truncated, files} of a diff's text: what DiffDetail shows of it."""
|
|
423
|
+
files, dropped = _parse(text, 300, 20000, 2000)
|
|
424
|
+
return dict(bytes=size, truncated=cut or dropped or not terminated or any(item['truncated'] for item in files),
|
|
425
|
+
files=files)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _fenced(text, cut, size):
|
|
429
|
+
body, terminated = fenced_diff(text)
|
|
430
|
+
if body is None:
|
|
431
|
+
return None
|
|
432
|
+
return parsed(body, cut and not terminated, len(body.encode('utf-8', 'surrogatepass')), terminated)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def load(reader, rel, fence=False):
|
|
436
|
+
"""{bytes, truncated, files} of a diff file, or of the last ```diff fence of a prompt; None
|
|
437
|
+
when it cannot be read or (fence) has no fence. Never mutate what it returns: it is cached."""
|
|
438
|
+
return cached(reader, rel, 'diff-fence' if fence else 'diff', _fenced if fence else parsed, _DIFFS)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def fence_bytes(reader, rel):
|
|
442
|
+
"""Size of a prompt's last ```diff fence, or None without one."""
|
|
443
|
+
def size(text, cut, total):
|
|
444
|
+
body = fenced_diff(text)[0]
|
|
445
|
+
return None if body is None else len(body.encode('utf-8', 'surrogatepass'))
|
|
446
|
+
return cached(reader, rel, 'fence-bytes', size)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def touched_of(reader, rel, fence=False):
|
|
450
|
+
"""touched() of a diff file, or of a prompt's last ```diff fence (None without one)."""
|
|
451
|
+
def paths(text, cut, total):
|
|
452
|
+
body = fenced_diff(text)[0] if fence else text
|
|
453
|
+
return None if body is None else touched(body)
|
|
454
|
+
return cached(reader, rel, 'touched-fence' if fence else 'touched', paths)
|