costwright 0.2.2__tar.gz → 0.2.3__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: costwright
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Static budget certificates for LLM-agent workflows (LangGraph / CrewAI / OpenAI Agents SDK). Backed by a machine-checked (Lean 4) cost-soundness theorem.
5
5
  Author: Hernán Inverso
6
6
  License: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "costwright"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Static budget certificates for LLM-agent workflows (LangGraph / CrewAI / OpenAI Agents SDK). Backed by a machine-checked (Lean 4) cost-soundness theorem."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.2.3"
@@ -76,6 +76,11 @@ def scan_file(path: Path):
76
76
  model_val = next((k.value.value for k in node.keywords
77
77
  if k.arg == "model" and isinstance(k.value, ast.Constant)
78
78
  and isinstance(k.value.value, str)), "")
79
+ # the model can also be the FIRST POSITIONAL arg — `ChatOpenAI("gpt-5")` (codex/Cursor r76); otherwise
80
+ # a reasoning model passed positionally would escape the reasoning detection below.
81
+ if not model_val and node.args and isinstance(node.args[0], ast.Constant) \
82
+ and isinstance(node.args[0].value, str):
83
+ model_val = node.args[0].value
79
84
  reasoning = any(model_val.startswith(p) for p in
80
85
  ("o1", "o3", "o4", "gpt-5")) if model_val else False
81
86
  # SOLO Chat-API constructors (audit-3 R2 gpt-5.5): el constructor `OpenAI` es
@@ -134,30 +139,38 @@ def scan_file(path: Path):
134
139
 
135
140
 
136
141
  def make_patch(path: Path, src: str, findings, cap_value: int) -> str:
137
- """Unified diff que agrega `kwarg=cap_value` a cada constructor sin cap.
138
- Edición textual mínima: insertar el kwarg tras el paréntesis de apertura del call.
139
- NUNCA escribe el archivo solo el diff (council 002 P0-2)."""
142
+ """Unified diff que agrega `kwarg=cap_value` como ÚLTIMO argumento de cada constructor sin cap.
143
+ Inserción basada en AST (robusta a args POSICIONALES, strings con paréntesis, y kwargs previos): el kwarg
144
+ va antes del `)` de cierre del call, NUNCA tras el `(` (eso produciría `Ctor(kwarg=…, "positional")` =
145
+ SyntaxError — codex/Cursor r76). NUNCA escribe el archivo — solo el diff (council 002 P0-2)."""
146
+ try:
147
+ tree = ast.parse(src)
148
+ except SyntaxError:
149
+ return ""
150
+ # map (lineno, constructor) → list of Call nodes, to insert at the exact end of the right call
151
+ by_key = {}
152
+ for node in ast.walk(tree):
153
+ if isinstance(node, ast.Call):
154
+ by_key.setdefault((node.lineno, call_name(node)), []).append(node)
140
155
  lines = src.splitlines(keepends=True)
141
156
  new_lines = list(lines)
142
- # de abajo hacia arriba para no correr line numbers
143
- for f in sorted((f for f in findings if f["kind"] == "missing"),
144
- key=lambda x: -x["line"]):
145
- i = f["line"] - 1
146
- if i >= len(new_lines):
147
- continue
157
+ edits = [] # (line_index, col, text) applied right-to-left so columns don't shift
158
+ for f in (f for f in findings if f["kind"] == "missing"):
159
+ cands = by_key.get((f["line"], f["constructor"]), [])
160
+ if len(cands) != 1:
161
+ continue # 0 or >1 matching calls on the line → ambiguous, skip (the finding is still reported)
162
+ call = cands[0]
163
+ if call.end_lineno != call.lineno or call.end_col_offset is None:
164
+ continue # multi-line call → skip (conservative)
165
+ i = call.lineno - 1
166
+ close = call.end_col_offset - 1 # column of the closing ')'
167
+ had_args = bool(call.args) or bool(call.keywords)
168
+ sep = ", " if had_args else ""
169
+ edits.append((i, close, f"{sep}{f['suggest_kwarg']}={cap_value}"))
170
+ # apply right-to-left (highest column on a line first) so earlier insertions don't shift later columns
171
+ for i, col, text in sorted(edits, key=lambda e: (e[0], -e[1])):
148
172
  line = new_lines[i]
149
- ctor = f["constructor"]
150
- # audit-3 (gemini P0): si hay >1 ocurrencia del constructor en la línea, NO parchear
151
- # (la inserción textual no sabe cuál es cuál) — conservador, el hallazgo igual se reporta
152
- if line.count(ctor + "(") != 1:
153
- continue
154
- idx = line.find(ctor + "(")
155
- if idx < 0:
156
- continue # constructor multilínea: skip (conservador)
157
- insert_at = idx + len(ctor) + 1
158
- rest = line[insert_at:]
159
- sep = "" if rest.lstrip().startswith(")") else ", "
160
- new_lines[i] = line[:insert_at] + f"{f['suggest_kwarg']}={cap_value}{sep}" + rest
173
+ new_lines[i] = line[:col] + text + line[col:]
161
174
  if new_lines == lines:
162
175
  return ""
163
176
  rel = str(path)
@@ -143,6 +143,11 @@ def cmd_caps(args) -> int:
143
143
  print(f"\n {total} finding(s) in {len(per_file)} file(s) "
144
144
  f"({scanned} scanned). Use --patch to emit a unified diff.")
145
145
  if args.patch:
146
+ if args.cap < 1:
147
+ # a cap of 0/negative is not an effective token bound — the patch would insert an inert kwarg
148
+ # that costwright itself flags `ineffective` (codex r75). Refuse instead of suggesting it.
149
+ print(f"costwright: --cap must be a positive integer (got {args.cap})", file=sys.stderr)
150
+ return 2
146
151
  chunks = []
147
152
  for p, (fs, src) in sorted(per_file.items()):
148
153
  d = caps_mod.make_patch(p.relative_to(root), src, fs, args.cap)
@@ -227,8 +227,16 @@ class Extractor(ast.NodeVisitor):
227
227
  # CrewAI Agent sin max_iter → default 20 (lo decide el mapper por-kind)
228
228
  elif last == "Crew":
229
229
  proc = next((k for k in n.keywords if k.arg == "process"), None)
230
- if proc is not None and "hierarchical" in ast.dump(proc.value):
231
- s.features.append({"feature": "hierarchical-manager", "line": n.lineno})
230
+ if proc is not None:
231
+ # a hierarchical Crew runs a MANAGER that re-delegates (an unbounded loop) → fail closed. The
232
+ # only SAFE value is a confirmed-sequential LITERAL (`Process.sequential` / "sequential"). A
233
+ # hierarchical literal, a VARIABLE (`mode = Process.hierarchical; process=mode` — codex r75), or
234
+ # any computed expression could be the manager → conservatively flag hierarchical-manager.
235
+ dump = ast.dump(proc.value)
236
+ confirmed_sequential = (isinstance(proc.value, (ast.Attribute, ast.Constant))
237
+ and "sequential" in dump and "hierarchical" not in dump)
238
+ if not confirmed_sequential:
239
+ s.features.append({"feature": "hierarchical-manager", "line": n.lineno})
232
240
 
233
241
  # caps de tokens en cualquier call (constructores de modelos, llamadas)
234
242
  for k in n.keywords:
@@ -243,6 +251,12 @@ class Extractor(ast.NodeVisitor):
243
251
 
244
252
  def _scan_invoke(s, n):
245
253
  """Busca recursion_limit / max_turns en el config del call-site (D2)."""
254
+ # a **kwargs spread on an invoke/run call is OPAQUE — it could carry a max_turns / recursion_limit that
255
+ # DISABLES the cap (e.g. `Runner.run(a, **{"max_turns": None})`, `app.invoke({}, **opts)`) and the bound
256
+ # would be unrecoverable → record an UNRESOLVED bound so the mapper fails closed (codex/Cursor r79).
257
+ if any(k.arg is None for k in n.keywords):
258
+ s.bounds.append({"param": "invoke-kwargs-spread", "value": None,
259
+ "source": "explicit", "line": n.lineno})
246
260
  for k in n.keywords:
247
261
  if k.arg == "max_turns":
248
262
  # distinguir None LITERAL (desactivación deliberada) de expresión no-constante
@@ -80,6 +80,8 @@ _SLA_MODES = {"strict", "balanced"}
80
80
  # it is a reported analysis the signed bundle BINDS (tamper-evidence) and whose ARITHMETIC fusion
81
81
  # re-checks in pure stdlib — but whose operational ASSUMPTIONS fusion canNOT verify (self-asserted).
82
82
  _INTERF_KIND = "tv-coupling-bound"
83
+ _CHANNEL_COVERED = "budget-cap-distribution-shift (channel 1 of N; N unknown)"
84
+ _SOURCE_ESTIMATOR = "eleata-verify.epsilon.interference_risk_bound"
83
85
  _ASSURANCE_LEVELS = {"self_asserted", "evidence_attached", "independently_reviewed"}
84
86
  _ASSUMPTIONS = {"A", "C", "D"} # the operational assumptions the (ii) bound needs
85
87
  # status is NEVER "bounded" (council P0-1: no word that reads as a guarantee). Derived by fusion.
@@ -423,8 +425,8 @@ def conditional_analysis_from_epsilon(epsilon_bound: dict, *, assumptions_attest
423
425
  _bound_auth = _inflate_alpha(float(_ab), _eps_auth, float(_cu))
424
426
  block = {
425
427
  "kind": _INTERF_KIND,
426
- "channel_covered": "budget-cap-distribution-shift (channel 1 of N; N unknown)",
427
- "source_estimator": "eleata-verify.epsilon.interference_risk_bound",
428
+ "channel_covered": _CHANNEL_COVERED,
429
+ "source_estimator": _SOURCE_ESTIMATOR,
428
430
  "verify_version": str(verify_version),
429
431
  "note": INTERF_NOTE,
430
432
  "channel1_conditional_risk_upper": _bound_auth, # RECOMPUTED, not the caller's alpha_effective
@@ -562,6 +564,16 @@ def _validate_conditional_analyses(ca: dict, risk_block: dict) -> dict:
562
564
  out["assumptions_complete"] = assumptions_complete
563
565
  out["bound_verification"] = bound_verification
564
566
  out["open_channels_non_exhaustive"] = True # forced — the list is non-exhaustive by construction
567
+ # FORCE the honesty/provenance fields to costwright's own constants — the caller cannot inject a
568
+ # `disclaimer="GUARANTEED SAFE"`, a reassuring `note`, a shrunk `open_channels=["none"]`, or a misleading
569
+ # `channel_covered`/`source_estimator` into the signed bundle (codex r78). Only measured PRIMITIVES
570
+ # (k, m, δ_eps, α, c, attestations) come from the caller; every honesty string is costwright's.
571
+ out["kind"] = _INTERF_KIND
572
+ out["channel_covered"] = _CHANNEL_COVERED
573
+ out["source_estimator"] = _SOURCE_ESTIMATOR
574
+ out["note"] = INTERF_NOTE
575
+ out["open_channels"] = list(_OPEN_CHANNELS)
576
+ out["disclaimer"] = NON_INTERFERENCE
565
577
  return {"channel1_budget_cap_risk": out}
566
578
 
567
579
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: costwright
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Static budget certificates for LLM-agent workflows (LangGraph / CrewAI / OpenAI Agents SDK). Backed by a machine-checked (Lean 4) cost-soundness theorem.
5
5
  Author: Hernán Inverso
6
6
  License: Apache-2.0
@@ -1 +0,0 @@
1
- __version__ = "0.2.2"
File without changes
File without changes
File without changes