proxy2model 2.0.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.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ """proxy2model — turn captured proxy traffic into an evidence-backed web-app model."""
2
+ __version__ = "2.0.0"
3
+
4
+ import os as _os
5
+
6
+ # the tool was called burp2model: its environment variables keep working
7
+ for _k, _v in list(_os.environ.items()):
8
+ if _k.startswith("BURP2MODEL_"):
9
+ _os.environ.setdefault("PROXY2MODEL_" + _k[len("BURP2MODEL_"):], _v)
10
+
11
+ from .ingest.parse import parse_items, Exchange # noqa: F401
12
+ from .core.model import build, to_dict, from_dict, cross_role, Model # noqa: F401
13
+ from .core.context import query, context_package # noqa: F401
14
+ from .core.graph import ReasonGraph, reason_graph, to_graphml, to_cypher, to_graph_json # noqa: F401
15
+ from .core.methodology import investigation_plan, methodology_package, rank_gaps # noqa: F401
16
+ from .core.changes import diff_models # noqa: F401
17
+ from .report.animate import render_terminal, render_svg # noqa: F401
18
+ from .core.domains import registrable_domain, public_suffix # noqa: F401
19
+ from .recon import osint # noqa: F401
@@ -0,0 +1,6 @@
1
+ """Allow `python -m proxy2model`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
proxy2model/atomic.py ADDED
@@ -0,0 +1,68 @@
1
+ """Crash-safe file writes, and keeping a damaged file aside instead of losing it.
2
+
3
+ Every output is written to a temporary file next to its target and renamed over it once complete,
4
+ so a crash, a full disk or Ctrl-C mid-write leaves the previous file intact, never half of a new one.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ from contextlib import contextmanager
12
+ from datetime import datetime
13
+
14
+
15
+ def _umask() -> int:
16
+ mask = os.umask(0)
17
+ os.umask(mask)
18
+ return mask
19
+
20
+
21
+ # the mode `open(path, "w")` gives a new file (read once: setting the umask to read it is not thread-safe)
22
+ _NEW_FILE_MODE = 0o666 & ~_umask()
23
+
24
+
25
+ @contextmanager
26
+ def atomic_write(path: str, mode: str = "w", encoding: str = "utf-8", perms: int | None = None):
27
+ """Like `open(path, mode)`, but the file only appears, whole, when the block finishes without error.
28
+
29
+ The file keeps an existing target's permissions, else gets the usual umask ones, or `perms` when given.
30
+ """
31
+ target = os.path.abspath(path)
32
+ if os.path.exists(target) and not os.path.isfile(target):
33
+ # /dev/stdout, a named pipe: nothing to rename over, write straight through
34
+ with open(target, mode, encoding=None if "b" in mode else encoding) as f:
35
+ yield f
36
+ return
37
+ fd, tmp = tempfile.mkstemp(prefix="." + os.path.basename(target) + ".", suffix=".tmp",
38
+ dir=os.path.dirname(target))
39
+ try:
40
+ with os.fdopen(fd, mode, encoding=None if "b" in mode else encoding) as f:
41
+ if perms is None:
42
+ try:
43
+ perms = os.stat(target).st_mode & 0o7777
44
+ except OSError:
45
+ perms = _NEW_FILE_MODE
46
+ os.chmod(tmp, perms)
47
+ yield f
48
+ f.flush()
49
+ os.fsync(f.fileno())
50
+ os.replace(tmp, target)
51
+ except BaseException:
52
+ try:
53
+ os.remove(tmp)
54
+ except OSError:
55
+ pass
56
+ raise
57
+
58
+
59
+ def keep_aside(path: str) -> str:
60
+ """Rename a damaged file to `<name>.damaged-<time>` so a fresh one can take its place. Returns the new path."""
61
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
62
+ dest = f"{path}.damaged-{stamp}"
63
+ n = 1
64
+ while os.path.exists(dest):
65
+ n += 1
66
+ dest = f"{path}.damaged-{stamp}-{n}"
67
+ os.replace(path, dest)
68
+ return dest
@@ -0,0 +1,3 @@
1
+ """proxy2model command-line interface."""
2
+
3
+ from .app import main # noqa: F401
@@ -0,0 +1,501 @@
1
+ """Commands that read a built model: query, q, graph, gaps, changes, openapi and the rest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ import sys
10
+
11
+ from ..atomic import atomic_write
12
+ from ..ingest.parse import parse_items
13
+ from ..core.model import build, from_dict, cross_role
14
+ from ..core.context import SOURCE, query as run_query
15
+ from ..report.animate import render_terminal, write_svg
16
+ from ..core.graph import (
17
+ ReasonGraph, reason_graph, to_graphml, to_cypher, to_graph_json,
18
+ )
19
+ from ..core.methodology import (
20
+ investigation_plan, methodology_package, rank_gaps,
21
+ METHODOLOGY_PROMPT, FALSIFY_PROMPT,
22
+ )
23
+ from ..core.changes import diff_models
24
+ from ..export.openapi import to_openapi
25
+ from ..db import store, bql
26
+ from . import ui
27
+ from .common import (
28
+ _init_key, _db_path, _model_path, _reload,
29
+ )
30
+
31
+
32
+ def cmd_q(args) -> int:
33
+ path = _db_path(args)
34
+ if path is None:
35
+ return 2
36
+ try:
37
+ conn = bql.connect(path)
38
+ except (ValueError, sqlite3.Error) as e:
39
+ print(f"could not open {path}: {e}", file=sys.stderr)
40
+ return 2
41
+
42
+ def run_one(text: str) -> int:
43
+ try:
44
+ res = bql.run_query(conn, text, limit=args.limit)
45
+ except (bql.BQLError, sqlite3.Error) as e:
46
+ print(f"error: {e}", file=sys.stderr)
47
+ return 2
48
+ print(bql.render_json(res) if args.format == "json" else bql.render_text(res))
49
+ return 0
50
+
51
+ try:
52
+ if args.query:
53
+ return run_one(" ".join(args.query))
54
+ print(f"proxy2model q · {args.webapp} · graph.db — `help` for the language, Ctrl-D to exit")
55
+ while True:
56
+ try:
57
+ line = input("bql> ").strip()
58
+ except (EOFError, KeyboardInterrupt):
59
+ print()
60
+ return 0
61
+ if line in ("exit", "quit"):
62
+ return 0
63
+ if line:
64
+ run_one(line)
65
+ finally:
66
+ conn.close()
67
+
68
+
69
+ def cmd_query(args) -> int:
70
+ path = _model_path(args)
71
+ if path is None:
72
+ return 2
73
+ m = _reload(path)
74
+ if m is None:
75
+ return 2
76
+ print(run_query(m, args.question))
77
+ return 0
78
+
79
+
80
+ def cmd_crossrole(args) -> int:
81
+ path = _model_path(args)
82
+ if path is None:
83
+ return 2
84
+ m = _reload(path)
85
+ if m is None:
86
+ return 2
87
+ missing = [r for r in (args.low, args.high) if r not in m.roles]
88
+ if missing:
89
+ print(f"role(s) not in this model: {', '.join(missing)} "
90
+ f"(model has: {', '.join(sorted(m.roles)) or 'none'}).\n"
91
+ f"Build each capture with --role <name> into the same --out/--webapp.",
92
+ file=sys.stderr)
93
+ return 2
94
+ result = cross_role(m, args.low, args.high)
95
+ if args.json:
96
+ print(json.dumps(result, indent=2))
97
+ return 0
98
+
99
+ def show(title, items):
100
+ print(f"{title} ({len(items)})")
101
+ for s in items:
102
+ ev = ", ".join(f"ev_{e}" for e in s["evidence"])
103
+ st = " ".join(f"{r}={v}" for r, v in s["statuses"].items())
104
+ print(f" {s['endpoint']:<44} {ev} {st}")
105
+ print(f" unknown: {s['unknown']} · next: {s['next']}")
106
+ print()
107
+
108
+ show(f"'{args.low}' reached privileged-looking endpoints", result["low_reached_privileged"])
109
+ show(f"'{args.high}' reached, '{args.low}' never tried", result["high_only"])
110
+ show("both roles got 2xx", result["shared_same_success"])
111
+ print("Each is a hypothesis, not a finding.")
112
+ return 0
113
+
114
+
115
+ def cmd_osint(args) -> int:
116
+ from ..recon import osint as osint_mod
117
+
118
+ host = args.host.strip().lower().replace("https://", "").replace("http://", "")
119
+ host = host.split("/")[0].split(":")[0]
120
+ if not re.match(r"^[a-z0-9.\-]{1,253}$", host) or "." not in host:
121
+ print(f"not a hostname: {args.host!r}", file=sys.stderr)
122
+ return 2
123
+ if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host):
124
+ print(f"{host} is an IP address — osint needs a hostname "
125
+ "(DNS, certificate transparency and RDAP are domain lookups)", file=sys.stderr)
126
+ return 2
127
+ # Active recon reaches out to the target and to public services. Gate it.
128
+ if not (args.yes or os.environ.get("PROXY2MODEL_OSINT_CONSENT") == "1"):
129
+ print(
130
+ "osint sends live requests to the target and to public services "
131
+ "(DNS, TLS, HTTP" + (", TCP ports" if args.ports else "") + ", CT logs, RDAP).\n"
132
+ f"Only run this against hosts you are authorized to test.\n"
133
+ f" target: {host}\n"
134
+ "Re-run with --yes to confirm you have authorization.",
135
+ file=sys.stderr)
136
+ return 2
137
+
138
+ result = osint_mod.run(host, timeout=args.timeout, want_ports=args.ports,
139
+ want_subdomains=not args.no_subdomains)
140
+ blob = json.dumps(result.to_dict(), indent=2)
141
+ saved = None
142
+ try:
143
+ if args.webapp:
144
+ outdir = os.path.join(args.out, args.webapp)
145
+ os.makedirs(outdir, exist_ok=True)
146
+ saved = os.path.join(outdir, "osint.json")
147
+ with atomic_write(saved) as f:
148
+ f.write(blob)
149
+ if args.out_file:
150
+ with atomic_write(args.out_file) as f:
151
+ f.write(blob)
152
+ saved = args.out_file
153
+ except OSError as e:
154
+ print(f"could not write osint report: {e}", file=sys.stderr)
155
+ return 2
156
+ if args.json:
157
+ print(blob)
158
+ else:
159
+ print(osint_mod.render_text(result))
160
+ if saved:
161
+ print(f"\nwrote {saved}", file=sys.stderr)
162
+ if args.webapp:
163
+ mp = os.path.join(args.out, args.webapp, "model.json")
164
+ if os.path.exists(mp):
165
+ try:
166
+ with open(mp, encoding="utf-8") as f:
167
+ model = from_dict(json.load(f))
168
+ dbs = store.write_db(model, os.path.join(args.out, args.webapp,
169
+ store.DB_NAME),
170
+ osint=result.to_dict())
171
+ if dbs.get("damaged"):
172
+ print(f"warning: graph.db was damaged; kept it aside as {dbs['damaged']}",
173
+ file=sys.stderr)
174
+ print(f"graph.db updated ({dbs['external_nodes']} external nodes, "
175
+ f"{dbs['osint_runs']} recon run(s) stored)", file=sys.stderr)
176
+ except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as e:
177
+ print(f"could not update graph.db: {e}", file=sys.stderr)
178
+ print("run the build again for this webapp to fold it into "
179
+ "report.html and context.json.", file=sys.stderr)
180
+ return 0
181
+
182
+
183
+ def _render_reason(rg, m) -> str:
184
+ """A compact text overview of the graph's reasoning views."""
185
+ L = [f"{m.name} — graph reasoning ({len(m.nodes)} nodes, {len(m.edges)} edges)", ""]
186
+ comms = rg.communities()
187
+ L.append(f"Communities ({len(comms)}) — functional areas")
188
+ for c in comms[:8]:
189
+ kinds = ", ".join(f"{v} {k}" for k, v in c["kinds"].items())
190
+ L.append(f" {c['id']} '{c['name']}' {c['size']} nodes ({kinds})")
191
+ L.append("")
192
+ L.append("Hubs — the load-bearing nodes")
193
+ for h in rg.hubs(8):
194
+ L.append(f" {h['degree']:>3} {h['type']:<12} {h['label']}")
195
+ L.append("")
196
+ zones = rg.trust_zones()
197
+ L.append("Trust zones — how identity reaches the surface")
198
+ for z, rows in zones.items():
199
+ L.append(f" {z:<18} {len(rows)} endpoint(s)")
200
+ L.append("")
201
+ coup = rg.coupling()
202
+ L.append(f"Coupling — {len(coup['cross_community'])} cross-area edge(s), "
203
+ f"{len(coup['to_third_party'])} to third parties")
204
+ L.append("")
205
+ L.append("Source: model graph · Model call: none")
206
+ return "\n".join(L)
207
+
208
+
209
+ def cmd_graph(args) -> int:
210
+ path = _model_path(args)
211
+ if path is None:
212
+ return 2
213
+ m = _reload(path)
214
+ if m is None:
215
+ return 2
216
+ rg = ReasonGraph(m)
217
+
218
+ if args.reach or args.blast or args.touched_by:
219
+ target = (args.reach or args.blast or args.touched_by).lower()
220
+ # rank: prefer a label hit over an id hit, a meaningful node over a
221
+ # parameter/operation/cookie, and the shortest label among equals
222
+ leaf = ("parameter", "operation", "cookie")
223
+ scored = []
224
+ for nid, n in m.nodes.items():
225
+ lbl = n.label.lower()
226
+ if target in lbl:
227
+ rank = 0 if lbl == target else 1
228
+ elif target in nid.lower():
229
+ rank = 2
230
+ else:
231
+ continue
232
+ scored.append(((rank, n.type in leaf, len(n.label)), nid))
233
+ if not scored:
234
+ print(f"no node matches {target!r}", file=sys.stderr)
235
+ return 2
236
+ scored.sort()
237
+ nid = scored[0][1]
238
+ if args.reach:
239
+ result = rg.reach(nid)
240
+ elif args.blast:
241
+ result = rg.blast(nid, depth=args.depth)
242
+ else:
243
+ result = rg.touched_by(nid, depth=args.depth)
244
+ print(json.dumps(result, indent=2))
245
+ return 0
246
+
247
+ fmt = args.format
248
+ if fmt == "graphml":
249
+ out = to_graphml(m)
250
+ elif fmt == "cypher":
251
+ out = to_cypher(m)
252
+ elif fmt == "json":
253
+ out = json.dumps(to_graph_json(m), indent=2)
254
+ elif fmt == "reason":
255
+ out = json.dumps(reason_graph(m), indent=2)
256
+ else:
257
+ out = _render_reason(rg, m)
258
+
259
+ if args.out_file:
260
+ try:
261
+ with atomic_write(args.out_file) as f:
262
+ f.write(out)
263
+ except OSError as e:
264
+ print(f"could not write {args.out_file}: {e}", file=sys.stderr)
265
+ return 2
266
+ print(f"wrote {args.out_file}", file=sys.stderr)
267
+ else:
268
+ print(out)
269
+ return 0
270
+
271
+
272
+ def _render_plan(plan: dict) -> str:
273
+ p = plan["posture"]
274
+ L = [f"{plan['app']} — investigation scaffold (scope: {', '.join(plan['scope']) or '?'})", ""]
275
+ L.append(f"{p['endpoints']} endpoints · {p['feature_areas']} feature areas · "
276
+ f"roles: {', '.join(p['roles']) or 'none'}")
277
+ L.append(f"{p['static_only']} code-only · {p['privileged']} privileged · "
278
+ f"{p['credentialed']} credentialed · {p['open_questions']} open questions")
279
+ L.append("")
280
+ L.append("RECON — establish first")
281
+ for r in plan["recon"]:
282
+ L.append(f" • {r['step']}")
283
+ L.append(f" why: {r['why']}")
284
+ L.append(f" how: {r['how']}")
285
+ L.append("")
286
+ L.append("LINES OF INQUIRY — by leverage")
287
+ for ln in plan["lines_of_inquiry"]:
288
+ if not ln["endpoints"]:
289
+ continue
290
+ L.append(f" [{ln['leverage']:>2}] {ln['area']} — {ln['why_it_matters']}")
291
+ if ln["reach"]:
292
+ L.append(f" reached {'; '.join(ln['reach'])}")
293
+ for s in ln["signals"][:5]:
294
+ ev = ", ".join(f"ev_{i}" for i in s["evidence"])
295
+ L.append(f" - {s['endpoint']}: {s['observation']} [{ev}]")
296
+ L.append(f" → {s['question']}")
297
+ L.append(f" next: {s['next']}")
298
+ for q in ln["open_questions"][:4]:
299
+ ent = f"{q['entity']}: " if q.get("entity") else ""
300
+ L.append(f" ? {ent}{q['unknown']} — {q['to_find_out']}")
301
+ L.append("")
302
+ if plan["watch"]["third_party_trust"]:
303
+ L.append("WATCH — trust leaving the app")
304
+ for e in plan["watch"]["third_party_trust"][:8]:
305
+ L.append(f" {e['from']} → {e['to']} ({e['edge'].lower()})")
306
+ L.append("")
307
+ L.append("This is a scaffold, not the methodology. Hand it to an AI with "
308
+ "`--prompt` to reason a sequenced, target-specific plan.")
309
+ L.append("Source: model graph · Model call: none")
310
+ return "\n".join(L)
311
+
312
+
313
+ def cmd_methodology(args) -> int:
314
+ path = _model_path(args)
315
+ if path is None:
316
+ return 2
317
+ m = _reload(path)
318
+ if m is None:
319
+ return 2
320
+ if args.json:
321
+ out = json.dumps(investigation_plan(m), indent=2)
322
+ elif args.prompt:
323
+ pkg = methodology_package(m)
324
+ out = ("\n".join(METHODOLOGY_PROMPT) + "\n\n"
325
+ + "MODEL AND SCAFFOLD (JSON):\n```json\n"
326
+ + json.dumps({"plan_scaffold": pkg["plan_scaffold"],
327
+ "model": pkg["model"]}, separators=(",", ":")) + "\n```")
328
+ else:
329
+ out = _render_plan(investigation_plan(m))
330
+ if args.out_file:
331
+ try:
332
+ with atomic_write(args.out_file) as f:
333
+ f.write(out)
334
+ except OSError as e:
335
+ print(f"could not write {args.out_file}: {e}", file=sys.stderr)
336
+ return 2
337
+ print(f"wrote {args.out_file}", file=sys.stderr)
338
+ else:
339
+ print(out)
340
+ return 0
341
+
342
+
343
+ def cmd_openapi(args) -> int:
344
+ path = _model_path(args)
345
+ if path is None:
346
+ return 2
347
+ m = _reload(path)
348
+ if m is None:
349
+ return 2
350
+ spec = to_openapi(m, host=args.host, include_static=args.include_static)
351
+ text = json.dumps(spec, indent=2)
352
+ if args.output in (None, "-"):
353
+ print(text)
354
+ return 0
355
+ try:
356
+ with atomic_write(args.output) as f:
357
+ f.write(text + "\n")
358
+ except OSError as e:
359
+ print(ui.error(f"could not write {args.output}: {e}"), file=sys.stderr)
360
+ return 2
361
+ print(ui.ok(f"wrote {args.output} ({len(spec['paths'])} paths, "
362
+ f"{sum(len(v) for v in spec['paths'].values())} operations)"), file=sys.stderr)
363
+ return 0
364
+
365
+
366
+ def cmd_gaps(args) -> int:
367
+ path = _model_path(args)
368
+ if path is None:
369
+ return 2
370
+ m = _reload(path)
371
+ if m is None:
372
+ return 2
373
+ g = rank_gaps(m)
374
+ if args.json:
375
+ print(json.dumps(g, indent=2))
376
+ return 0
377
+ print(f"{g['app']} — gaps, ranked by how much resolving each improves the picture")
378
+ print()
379
+ for i, gap in enumerate(g["gaps"], 1):
380
+ print(f"#{i} [{gap['leverage']:>2}] {gap['entity']}")
381
+ print(f" unknown: {gap['unknown']}")
382
+ print(f" next: {gap['next']}")
383
+ print()
384
+ print("absence = a gap in observation, never a statement about the target")
385
+ print(SOURCE)
386
+ return 0
387
+
388
+
389
+ def cmd_changes(args) -> int:
390
+ new = _reload(_model_path(args)) if _model_path(args) else None
391
+ if new is None:
392
+ return 2
393
+ if bool(args.against) == bool(args.against_build):
394
+ print("give exactly one of --against OLD_MODEL_JSON or --against-build "
395
+ "(an id from graph.db's build history, or `previous`)", file=sys.stderr)
396
+ return 2
397
+ try:
398
+ if args.against:
399
+ with open(args.against, encoding="utf-8") as f:
400
+ old = from_dict(json.load(f))
401
+ else:
402
+ dbp = _db_path(args)
403
+ if dbp is None:
404
+ return 2
405
+ conn = store.open_db(dbp)
406
+ try:
407
+ bid = None if args.against_build == "previous" else int(args.against_build)
408
+ if bid is None:
409
+ rows = conn.execute("SELECT id FROM builds ORDER BY id DESC LIMIT 2").fetchall()
410
+ if len(rows) < 2:
411
+ print("graph.db holds only one build so far — build again after "
412
+ "the app changes", file=sys.stderr)
413
+ return 2
414
+ bid = rows[1][0]
415
+ snap = store.load_build(conn, bid)
416
+ finally:
417
+ conn.close()
418
+ if snap is None:
419
+ print(f"no build #{bid} in graph.db", file=sys.stderr)
420
+ return 2
421
+ old = from_dict(snap)
422
+ except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as e:
423
+ what = f"--against {args.against}" if args.against else "the stored build"
424
+ print(f"could not load {what}: {e}", file=sys.stderr)
425
+ return 2
426
+ d = diff_models(old, new)
427
+ if args.json:
428
+ print(json.dumps(d, indent=2))
429
+ return 0
430
+ print(f"{d['app']} — what changed (+{d['totals']['appeared']} appeared, "
431
+ f"-{d['totals']['gone']} gone)")
432
+ print()
433
+ if not d["areas"]:
434
+ print("No drift between these two models.")
435
+ return 0
436
+ for a in d["areas"]:
437
+ print(f"● {a['area']}")
438
+ for x in a["appeared"]:
439
+ ev = ", ".join(f"ev_{i}" for i in x.get("evidence", []))
440
+ print(f" + {x['kind']:<11} {x['label']}")
441
+ print(f" {x['why']}" + (f" [{ev}]" if ev else ""))
442
+ for x in a["gone"]:
443
+ print(f" - {x['kind']:<11} {x['label']}")
444
+ print(f" {x['why']}")
445
+ print()
446
+ print(d["note"])
447
+ print(SOURCE)
448
+ return 0
449
+
450
+
451
+ def cmd_falsify(args) -> int:
452
+ path = _model_path(args)
453
+ if path is None:
454
+ return 2
455
+ m = _reload(path)
456
+ if m is None:
457
+ return 2
458
+ pkg = methodology_package(m, prompt=FALSIFY_PROMPT)
459
+ out = ("\n".join(FALSIFY_PROMPT) + "\n\n"
460
+ + "MODEL AND HYPOTHESES (JSON):\n```json\n"
461
+ + json.dumps({"plan_scaffold": pkg["plan_scaffold"],
462
+ "model": pkg["model"]}, indent=2) + "\n```")
463
+ if args.out_file:
464
+ try:
465
+ with atomic_write(args.out_file) as f:
466
+ f.write(out)
467
+ except OSError as e:
468
+ print(f"could not write {args.out_file}: {e}", file=sys.stderr)
469
+ return 2
470
+ print(f"wrote {args.out_file}", file=sys.stderr)
471
+ else:
472
+ print(out)
473
+ return 0
474
+
475
+
476
+ def cmd_animate(args) -> int:
477
+ """Standalone animation from an existing model or a fresh build."""
478
+ if args.log:
479
+ _init_key()
480
+ try:
481
+ m = build(list(parse_items(args.log)), name=args.webapp)
482
+ except (OSError, ValueError, SyntaxError) as e:
483
+ print(f"could not read {args.log}: {e}", file=sys.stderr)
484
+ return 2
485
+ else:
486
+ path = _model_path(args)
487
+ if path is None:
488
+ return 2
489
+ m = _reload(path)
490
+ if m is None:
491
+ return 2
492
+ if args.svg:
493
+ try:
494
+ write_svg(m, args.svg)
495
+ except OSError as e:
496
+ print(f"could not write {args.svg}: {e}", file=sys.stderr)
497
+ return 2
498
+ print(f"wrote {args.svg}")
499
+ else:
500
+ render_terminal(m, fast=args.no_type)
501
+ return 0