@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. 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)