forgecommandline 0.8.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.
- forgecli/__init__.py +2 -0
- forgecli/ads.py +322 -0
- forgecli/agent.py +1093 -0
- forgecli/art.py +574 -0
- forgecli/attach.py +246 -0
- forgecli/brief.py +215 -0
- forgecli/browsercheck.py +99 -0
- forgecli/checkpoints.py +81 -0
- forgecli/city.py +833 -0
- forgecli/cli.py +1668 -0
- forgecli/config.py +194 -0
- forgecli/courier.py +177 -0
- forgecli/courier_data.py +164 -0
- forgecli/courier_hd.py +431 -0
- forgecli/courier_hd_data.bin +0 -0
- forgecli/courier_hd_data.py +19 -0
- forgecli/ghlogin.py +76 -0
- forgecli/github.py +54 -0
- forgecli/guards.py +403 -0
- forgecli/hooks.py +50 -0
- forgecli/httpapi.py +434 -0
- forgecli/images.py +110 -0
- forgecli/keys.py +152 -0
- forgecli/keyvault.py +320 -0
- forgecli/mcp.py +674 -0
- forgecli/memory.py +42 -0
- forgecli/openapi.py +331 -0
- forgecli/parallel.py +308 -0
- forgecli/prices.py +72 -0
- forgecli/procs.py +144 -0
- forgecli/providers.py +758 -0
- forgecli/quality.py +192 -0
- forgecli/repomap.py +78 -0
- forgecli/roster.py +227 -0
- forgecli/safety.py +93 -0
- forgecli/sandbox.py +83 -0
- forgecli/sessions.py +97 -0
- forgecli/skills.py +446 -0
- forgecli/tools.py +1184 -0
- forgecli/ui.py +860 -0
- forgecli/worktrees.py +79 -0
- forgecommandline-0.8.0.data/data/skills/commit/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/debug/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/explain/SKILL.md +14 -0
- forgecommandline-0.8.0.data/data/skills/init/SKILL.md +19 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/agent-skills-MIT.txt +21 -0
- forgecommandline-0.8.0.data/data/skills/library/LICENSES/wondelai-MIT.txt +23 -0
- forgecommandline-0.8.0.data/data/skills/library/README.md +9 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/accessibility-checklist.md +160 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/definition-of-done.md +67 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/observability-checklist.md +92 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/orchestration-patterns.md +370 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/performance-checklist.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/security-checklist.md +248 -0
- forgecommandline-0.8.0.data/data/skills/library/_references/testing-patterns.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/api-and-interface-design/SKILL.md +367 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/SKILL.md +279 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/artifact-templates.md +157 -0
- forgecommandline-0.8.0.data/data/skills/library/architecture-optimization/references/methods.md +87 -0
- forgecommandline-0.8.0.data/data/skills/library/awwwards-tier-web-application/SKILL.md +877 -0
- forgecommandline-0.8.0.data/data/skills/library/browser-testing-with-devtools/SKILL.md +317 -0
- forgecommandline-0.8.0.data/data/skills/library/ci-cd-and-automation/SKILL.md +390 -0
- forgecommandline-0.8.0.data/data/skills/library/code-review-and-quality/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/code-simplification/SKILL.md +331 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/SKILL.md +311 -0
- forgecommandline-0.8.0.data/data/skills/library/constraint-driven-development/references/floor-guard.md +182 -0
- forgecommandline-0.8.0.data/data/skills/library/context-engineering/SKILL.md +353 -0
- forgecommandline-0.8.0.data/data/skills/library/debugging-and-error-recovery/SKILL.md +300 -0
- forgecommandline-0.8.0.data/data/skills/library/deprecation-and-migration/SKILL.md +247 -0
- forgecommandline-0.8.0.data/data/skills/library/documentation-and-adrs/SKILL.md +297 -0
- forgecommandline-0.8.0.data/data/skills/library/doubt-driven-development/SKILL.md +243 -0
- forgecommandline-0.8.0.data/data/skills/library/frontend-ui-engineering/SKILL.md +340 -0
- forgecommandline-0.8.0.data/data/skills/library/git-workflow-and-versioning/SKILL.md +355 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/SKILL.md +193 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/examples.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/frameworks.md +99 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/refinement-criteria.md +113 -0
- forgecommandline-0.8.0.data/data/skills/library/idea-refine/scripts/idea-refine.sh +15 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/SKILL.md +275 -0
- forgecommandline-0.8.0.data/data/skills/library/improve-code-quality/references/artifact-templates.md +101 -0
- forgecommandline-0.8.0.data/data/skills/library/incremental-implementation/SKILL.md +249 -0
- forgecommandline-0.8.0.data/data/skills/library/interview-me/SKILL.md +235 -0
- forgecommandline-0.8.0.data/data/skills/library/observability-and-instrumentation/SKILL.md +238 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/SKILL.md +267 -0
- forgecommandline-0.8.0.data/data/skills/library/performance-optimization/references/optimization-patterns.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/planning-and-task-breakdown/SKILL.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/SKILL.md +203 -0
- forgecommandline-0.8.0.data/data/skills/library/remove-technical-debt/references/artifact-templates.md +79 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/security-and-hardening/references/hardening-patterns.md +324 -0
- forgecommandline-0.8.0.data/data/skills/library/shipping-and-launch/SKILL.md +336 -0
- forgecommandline-0.8.0.data/data/skills/library/source-driven-development/SKILL.md +216 -0
- forgecommandline-0.8.0.data/data/skills/library/spec-driven-development/SKILL.md +259 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/SKILL.md +207 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/building-blocks.md +272 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/common-designs.md +257 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/database-scaling.md +295 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/estimation-numbers.md +250 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/four-step-process.md +286 -0
- forgecommandline-0.8.0.data/data/skills/library/system-design/references/reliability-operations.md +373 -0
- forgecommandline-0.8.0.data/data/skills/library/test-driven-development/SKILL.md +398 -0
- forgecommandline-0.8.0.data/data/skills/library/using-agent-skills/SKILL.md +192 -0
- forgecommandline-0.8.0.data/data/skills/review/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/run/SKILL.md +17 -0
- forgecommandline-0.8.0.data/data/skills/security-review/SKILL.md +20 -0
- forgecommandline-0.8.0.data/data/skills/simplify/SKILL.md +18 -0
- forgecommandline-0.8.0.data/data/skills/test/SKILL.md +16 -0
- forgecommandline-0.8.0.data/data/skills/web-build/SKILL.md +25 -0
- forgecommandline-0.8.0.dist-info/METADATA +357 -0
- forgecommandline-0.8.0.dist-info/RECORD +113 -0
- forgecommandline-0.8.0.dist-info/WHEEL +5 -0
- forgecommandline-0.8.0.dist-info/entry_points.txt +2 -0
- forgecommandline-0.8.0.dist-info/top_level.txt +1 -0
forgecli/guards.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Hallucination guards.
|
|
2
|
+
|
|
3
|
+
Two kinds of check, both cheap and offline:
|
|
4
|
+
|
|
5
|
+
* Write guards run inside write_file / edit_file: they refuse lazy content (placeholders such as
|
|
6
|
+
"... rest of the code unchanged ...") and edits that change nothing.
|
|
7
|
+
* The claim check runs on a final answer: every claim the answer makes ("tests pass", "fixed",
|
|
8
|
+
"installed", "created foo.py") must be backed by something a tool actually did this turn.
|
|
9
|
+
An unbacked claim is bounced back to the model once; if it is still there, the answer is shown
|
|
10
|
+
marked as unverified.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
# --- placeholders ------------------------------------------------------------------------------
|
|
20
|
+
# Phrases a model writes instead of code it couldn't be bothered to produce. Matched per line and
|
|
21
|
+
# only when the line looks like a comment / ellipsis line, so prose such as a README is left alone.
|
|
22
|
+
_PLACEHOLDER = re.compile(
|
|
23
|
+
r"(rest|remainder) of (the )?(code|file|function|class|implementation|component|logic|method)s?"
|
|
24
|
+
r"|(existing|unchanged|previous|original|same|other) (code|content|lines|implementation|methods|logic)"
|
|
25
|
+
r" *(here|goes here|remains?|stays?|as before|unchanged|omitted)"
|
|
26
|
+
r"|(implement(ation)?|add|put|insert) (the |your )?(logic|code|implementation|rest) here"
|
|
27
|
+
r"|your (code|logic|implementation) (goes )?here"
|
|
28
|
+
r"|todo:? *(implement|add (the )?(logic|code|implementation)|fill in)"
|
|
29
|
+
r"|(code|content|lines|methods) (omitted|truncated) for brevity"
|
|
30
|
+
r"|\.\.\. *(existing|unchanged|rest|same|more|other)\b",
|
|
31
|
+
re.I)
|
|
32
|
+
_COMMENTISH = re.compile(r"^\s*(#|//|/\*|\*|<!--|--|;|\.\.\.|…|\{/\*|rem\b)", re.I)
|
|
33
|
+
_TODO = re.compile(r"\b(?:TODO|FIXME|XXX)\b", re.I)
|
|
34
|
+
_FUNCTION_HEADER = re.compile(r"^(\s*)(?:async\s+)?def\s+\w+\s*\(.*\)\s*(?:->.*?)?\s*:\s*(.*?)\s*$")
|
|
35
|
+
_STUB_BODY = re.compile(r"(?:pass|\.\.\.|raise\s+(?:NotImplementedError|NotImplemented)(?:\(.*\))?)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def placeholders(content: str, old: str = "") -> list[str]:
|
|
39
|
+
"""Placeholder lines in content that weren't already in old."""
|
|
40
|
+
before = set(old.splitlines())
|
|
41
|
+
before_stripped = {line.strip() for line in before}
|
|
42
|
+
hits = []
|
|
43
|
+
lines = content.splitlines()
|
|
44
|
+
for line in lines:
|
|
45
|
+
s = line.strip()
|
|
46
|
+
if s in before or not s:
|
|
47
|
+
continue
|
|
48
|
+
if _TODO.search(s) and _COMMENTISH.match(s):
|
|
49
|
+
hits.append(s[:120])
|
|
50
|
+
continue
|
|
51
|
+
if _PLACEHOLDER.search(s) and (_COMMENTISH.match(s) or len(s) < 60 and s.startswith(("...", "…"))):
|
|
52
|
+
hits.append(s[:120])
|
|
53
|
+
for i, line in enumerate(lines):
|
|
54
|
+
header = _FUNCTION_HEADER.match(line)
|
|
55
|
+
if not header:
|
|
56
|
+
continue
|
|
57
|
+
base_indent = len(header.group(1).expandtabs(8))
|
|
58
|
+
statements = [header.group(2).strip()] if header.group(2).strip() else []
|
|
59
|
+
for body_line in lines[i + 1:]:
|
|
60
|
+
if not body_line.strip() or _COMMENTISH.match(body_line):
|
|
61
|
+
continue
|
|
62
|
+
indent = len(body_line) - len(body_line.lstrip(" \t"))
|
|
63
|
+
if indent <= base_indent:
|
|
64
|
+
break
|
|
65
|
+
statements.append(body_line.strip())
|
|
66
|
+
existing_stub = (line.strip() in before_stripped if header.group(2).strip() else
|
|
67
|
+
line.strip() in before_stripped and all(s in before_stripped for s in statements))
|
|
68
|
+
if statements and all(_STUB_BODY.fullmatch(s) for s in statements) and not existing_stub:
|
|
69
|
+
hits.append(line.strip()[:120])
|
|
70
|
+
return hits
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- the evidence a turn produced ---------------------------------------------------------------
|
|
74
|
+
@dataclass
|
|
75
|
+
class Step:
|
|
76
|
+
name: str
|
|
77
|
+
args: dict
|
|
78
|
+
ok: bool
|
|
79
|
+
out: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Ledger:
|
|
84
|
+
steps: list[Step] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
def add(self, name: str, args: dict, result: str) -> None:
|
|
87
|
+
output = result or ""
|
|
88
|
+
if len(output) > 4000:
|
|
89
|
+
output = output[:2000] + "\n… output truncated …\n" + output[-2000:]
|
|
90
|
+
self.steps.append(Step(name, args if isinstance(args, dict) else {}, succeeded(name, result), output))
|
|
91
|
+
|
|
92
|
+
def clear(self) -> None:
|
|
93
|
+
self.steps.clear()
|
|
94
|
+
|
|
95
|
+
def last_change(self) -> int:
|
|
96
|
+
"""Index of the last step that changed files (-1 if none)."""
|
|
97
|
+
for i in range(len(self.steps) - 1, -1, -1):
|
|
98
|
+
s = self.steps[i]
|
|
99
|
+
if s.ok and s.name in ("write_file", "edit_file", "apply_patch"):
|
|
100
|
+
return i
|
|
101
|
+
return -1
|
|
102
|
+
|
|
103
|
+
def after_change(self) -> list[Step]:
|
|
104
|
+
return self.steps[self.last_change() + 1:]
|
|
105
|
+
|
|
106
|
+
def touched(self) -> set[str]:
|
|
107
|
+
"""Every path-ish string any tool was given or printed this turn (lower-cased)."""
|
|
108
|
+
seen = set()
|
|
109
|
+
for s in self.steps:
|
|
110
|
+
for v in s.args.values():
|
|
111
|
+
if isinstance(v, str):
|
|
112
|
+
seen.add(v.lower())
|
|
113
|
+
seen.add(s.out.lower())
|
|
114
|
+
return seen
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def succeeded(name: str, result: str) -> bool:
|
|
118
|
+
r = (result or "").lstrip()
|
|
119
|
+
if r.startswith(("Error", "The user denied", "Interrupted", "Stopped before", "Command was stopped")):
|
|
120
|
+
return False
|
|
121
|
+
if name == "run_shell":
|
|
122
|
+
m = re.match(r"(?:The command exited right away \()?exit code (-?\d+)", r)
|
|
123
|
+
if m:
|
|
124
|
+
return m.group(1) == "0"
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- claims -------------------------------------------------------------------------------------
|
|
129
|
+
_TESTISH = re.compile(r"\b(test|tests|pytest|unittest|jest|vitest|mocha|spec|check|verify|ctest|tox|nox)\b"
|
|
130
|
+
r"|cargo test|go test|npm (run )?test|_test\.|test_\w+", re.I)
|
|
131
|
+
_TEST_PASS = re.compile(
|
|
132
|
+
r"\b(?:[1-9]\d*\s+(?:tests?\s+)?passed|[1-9]\d*\s+passing|test result:\s*ok|"
|
|
133
|
+
r"100%\s+tests?\s+passed|all\s+tests?\s+passed|tests run:\s*[1-9]\d*[,;].*?"
|
|
134
|
+
r"failures:\s*0[,;].*?errors:\s*0|[1-9]\d*\s+tests? completed,\s*0 failed|"
|
|
135
|
+
r"(?m:^ok\s+\S+\s+\d+(?:\.\d+)?s?\s*$)|OK\s*\(\s*[1-9]\d*\s+tests?\s*\))", re.I)
|
|
136
|
+
_TEST_FAIL = re.compile(
|
|
137
|
+
r"\b[1-9]\d*\s+(?:failed|failures?|errors?)\b|\b(?:tests?|test suite)\s+failed\b|"
|
|
138
|
+
r"\b0\s+(?:tests?|passed|passing)\b|\bno tests? (?:ran|found|collected)\b", re.I)
|
|
139
|
+
_TEST_COMMAND = re.compile(
|
|
140
|
+
r"(?:^|[;&|]\s*)(?:(?:uv\s+run|poetry\s+run)\s+)?(?:"
|
|
141
|
+
r"pytest(?:\.exe)?\b|"
|
|
142
|
+
r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?\s+-m\s+(?:pytest|unittest)\b|"
|
|
143
|
+
r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?\s+[\w./\\-]*(?:test_[\w.-]*\.py|[\w.-]*_test\.py)\b|"
|
|
144
|
+
r"(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|"
|
|
145
|
+
r"(?:npx\s+)?(?:jest|vitest|mocha)\b|cargo\s+test\b|go\s+test\b|"
|
|
146
|
+
r"(?:mvn|gradle|gradlew)\s+[^|;&]*\btest\b|tox(?:\s|$)|nox(?:\s|$)|ctest\b)", re.I)
|
|
147
|
+
_BUILD_COMMAND = re.compile(
|
|
148
|
+
r"(?:^|[;&|]\s*)(?:(?:uv\s+run|poetry\s+run)\s+)?(?:"
|
|
149
|
+
r"(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|"
|
|
150
|
+
r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?\s+-m\s+(?:compileall|py_compile)\b|"
|
|
151
|
+
r"tsc(?:\.cmd)?\b|make(?:\.exe)?\b|cargo\s+build\b|go\s+build\b|"
|
|
152
|
+
r"(?:\.?[\\/])?(?:mvn|gradle|gradlew)\s+[^|;&]*\b(?:build|package|compile)\b|"
|
|
153
|
+
r"dotnet\s+build\b|msbuild(?:\.exe)?\b|cmake\s+--build\b|"
|
|
154
|
+
r"(?:gcc|g\+\+|clang(?:\+\+)?)\b)", re.I)
|
|
155
|
+
_INSTALLISH = re.compile(r"\b(install|add|pip|npm|pnpm|yarn|uv|poetry|conda|choco|winget|brew|apt|"
|
|
156
|
+
r"cargo|go get|gem|composer)\b", re.I)
|
|
157
|
+
|
|
158
|
+
_CLAIMS = [
|
|
159
|
+
("tests", re.compile(r"\b(all |the )?(\d+ )?tests? (now |all |still )*(pass|passed|passing|succeed|"
|
|
160
|
+
r"succeeded|are green|run green|go green)\b|\btest suite (passes|passed|is green)", re.I)),
|
|
161
|
+
("build", re.compile(r"\b(build|compil(ation|es))\b[^.\n]{0,20}\b(succeeds|succeeded|passes|passed|"
|
|
162
|
+
r"works|is clean|without errors)", re.I)),
|
|
163
|
+
("installed", re.compile(r"\b(i(?:'ve| have)? |successfully |now )installed\b|\binstalled successfully\b",
|
|
164
|
+
re.I)),
|
|
165
|
+
("works", re.compile(r"\b(it|this|that|everything|the (app|code|fix|page|script|server|function|bug))"
|
|
166
|
+
r"( now| should( now)?)? (works?|is working|runs? (fine|correctly|cleanly)|"
|
|
167
|
+
r"is (now )?fixed)\b|\bshould (now )?work\b|\b(i(?:'ve| have)? )?fixed (it|the)\b", re.I)),
|
|
168
|
+
]
|
|
169
|
+
# A sentence that hedges, negates or asks is not a claim.
|
|
170
|
+
_HEDGE = re.compile(r"\b(not|n't|never|unverified|unable|couldn't|could not|didn't|haven't|wasn't|"
|
|
171
|
+
r"if|once|after you|when you|try|whether|may|might|until)\b|\?", re.I)
|
|
172
|
+
|
|
173
|
+
_PATH_TOKEN = re.compile(r"`([^`]{2,400})`|\]\(([^)]{2,400})\)")
|
|
174
|
+
_EXT = re.compile(r"\.(py|js|mjs|cjs|ts|tsx|jsx|json|md|txt|html?|css|scss|toml|ya?ml|ini|cfg|sh|ps1|bat|"
|
|
175
|
+
r"go|rs|java|kt|c|cc|cpp|h|hpp|cs|rb|php|swift|sql|xml|csv|env|lock|vue|svelte)$", re.I)
|
|
176
|
+
_PLAIN_PATH = re.compile(
|
|
177
|
+
r"(?<![\w`])((?:[A-Za-z]:[\\/])?(?:[\w.@+-]+[\\/])*[\w.@+-]+\."
|
|
178
|
+
r"(?:py|js|mjs|cjs|ts|tsx|jsx|json|md|txt|html?|css|scss|toml|ya?ml|ini|cfg|sh|ps1|bat|"
|
|
179
|
+
r"go|rs|java|kt|c|cc|cpp|h|hpp|cs|rb|php|swift|sql|xml|csv|env|lock|vue|svelte))"
|
|
180
|
+
r"(?=$|[\s):,;.!?])", re.I)
|
|
181
|
+
_SYMBOL = re.compile(r"`([A-Za-z_]\w*)\s*\([^`\n]*\)`")
|
|
182
|
+
_SYMBOL_CONTEXT = re.compile(
|
|
183
|
+
r"\b(function|method|class|symbol|call(?:s|ed)?|defined|declared|implements|uses|imports?)\b", re.I)
|
|
184
|
+
_CREATED = re.compile(r"\b(created|wrote|added|generated|saved)\b", re.I)
|
|
185
|
+
_CHANGE_TOOLS = {"write_file", "edit_file", "apply_patch"}
|
|
186
|
+
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache", ".next", "dist", "build"}
|
|
187
|
+
_GITHUB_CREATION = re.compile(r"\b(?:opened|created)\b.{0,50}\b(?:pull request|PR|issue)\b", re.I)
|
|
188
|
+
_GITHUB_URL = re.compile(r"https://github\.com/[^\s\"'<>]+/(?:pull|issues)/\d+")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sentences(text: str) -> list[str]:
|
|
192
|
+
text = re.sub(r"```.*?```", " ", text, flags=re.S) # code samples aren't claims
|
|
193
|
+
return [s.strip() for s in re.split(r"(?<=[.!])\s+|\n+", text) if s.strip()]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_succeeded(step: Step) -> bool:
|
|
197
|
+
"""Require a successful command and recognizable positive test output, not just exit code zero."""
|
|
198
|
+
command = step.args.get("command", "")
|
|
199
|
+
return (step.ok and _TEST_COMMAND.search(command) is not None and
|
|
200
|
+
_TEST_PASS.search(step.out) is not None and _TEST_FAIL.search(step.out) is None)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_claims(answer: str, ledger: Ledger, root: pathlib.Path) -> list[str]:
|
|
204
|
+
"""Problems with the answer: claims no tool backed up, and files that don't exist."""
|
|
205
|
+
problems: list[str] = []
|
|
206
|
+
after = ledger.after_change()
|
|
207
|
+
all_shells = [s for s in ledger.steps if s.name == "run_shell"]
|
|
208
|
+
test_runs_after = [s for s in after if s.name == "run_shell" and
|
|
209
|
+
_TESTISH.search(s.args.get("command", ""))]
|
|
210
|
+
latest_test_after = test_runs_after[-1] if test_runs_after else None
|
|
211
|
+
passing_tests_after = latest_test_after is not None and test_succeeded(latest_test_after)
|
|
212
|
+
checked_after = [s for s in after if s.ok and (
|
|
213
|
+
s.name in ("open_url", "process") or
|
|
214
|
+
s.name == "run_shell" and (
|
|
215
|
+
_BUILD_COMMAND.search(s.args.get("command", "")) or test_succeeded(s)))]
|
|
216
|
+
changed = ledger.last_change() >= 0
|
|
217
|
+
github_outputs = [step.out for step in ledger.steps if step.ok and
|
|
218
|
+
step.name in {"github_request", "http_request"}]
|
|
219
|
+
github_urls = _GITHUB_URL.findall("\n".join(github_outputs))
|
|
220
|
+
from . import github
|
|
221
|
+
kinds = set()
|
|
222
|
+
for sent in _sentences(answer):
|
|
223
|
+
if _HEDGE.search(sent):
|
|
224
|
+
continue
|
|
225
|
+
for kind, rx in _CLAIMS:
|
|
226
|
+
if kind not in kinds and rx.search(sent):
|
|
227
|
+
kinds.add(kind)
|
|
228
|
+
if "tests" in kinds and not passing_tests_after:
|
|
229
|
+
test_runs = [s for s in all_shells if _TESTISH.search(s.args.get("command", ""))]
|
|
230
|
+
latest_test = test_runs[-1] if test_runs else None
|
|
231
|
+
problems.append("you said the tests pass, but " + (
|
|
232
|
+
"the last test run did not show a passing result" if latest_test and not test_succeeded(latest_test) else
|
|
233
|
+
"no test command showed a passing result" + (" after your last change" if changed else "") + " this turn"))
|
|
234
|
+
if "build" in kinds and not any(
|
|
235
|
+
s.ok and _BUILD_COMMAND.search(s.args.get("command", "")) for s in after if s.name == "run_shell"):
|
|
236
|
+
problems.append("you said it builds, but no build ran successfully"
|
|
237
|
+
+ (" after your last change" if changed else "") + " this turn")
|
|
238
|
+
if "installed" in kinds and not any(_INSTALLISH.search(s.args.get("command", "")) for s in all_shells if s.ok):
|
|
239
|
+
problems.append("you said something was installed, but no install command succeeded this turn")
|
|
240
|
+
# A plain question ("skills", "how does X work") has no change to vouch for: words like
|
|
241
|
+
# "works" there describe the code, not a fix, so a turn that used no tools at all is not checked.
|
|
242
|
+
if "works" in kinds and ledger.steps and not checked_after and "tests" not in kinds:
|
|
243
|
+
problems.append("you said it works / is fixed, but nothing was run or re-checked"
|
|
244
|
+
+ (" after your last change" if changed else " this turn"))
|
|
245
|
+
for sent in _sentences(answer):
|
|
246
|
+
if _GITHUB_CREATION.search(sent):
|
|
247
|
+
claimed_urls = _GITHUB_URL.findall(sent)
|
|
248
|
+
if not any(url in github_urls and
|
|
249
|
+
github.pr_url_ok(url, github_outputs)
|
|
250
|
+
for url in claimed_urls):
|
|
251
|
+
problems.append("you claimed a GitHub pull request or issue was created without including "
|
|
252
|
+
"a returned GitHub URL")
|
|
253
|
+
if not _CREATED.search(sent):
|
|
254
|
+
continue
|
|
255
|
+
paths = _path_candidates(sent)
|
|
256
|
+
if paths and not any(_written_this_turn(path, ledger, root) for path in paths):
|
|
257
|
+
problems.append("you said you created a file, but no successful file write for its path "
|
|
258
|
+
"happened this turn: " + ", ".join(paths[:5]))
|
|
259
|
+
missing = missing_paths(answer, ledger, root)
|
|
260
|
+
if missing:
|
|
261
|
+
problems.append("these files you mention don't exist: " + ", ".join(missing[:5]))
|
|
262
|
+
missing_names = missing_symbols(answer, root, ledger)
|
|
263
|
+
if missing_names:
|
|
264
|
+
problems.append("these function names were not found in the project: " + ", ".join(missing_names[:5]))
|
|
265
|
+
return problems
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _path_candidates(text: str) -> list[str]:
|
|
269
|
+
text = re.sub(r"```.*?```", " ", text, flags=re.S)
|
|
270
|
+
candidates = []
|
|
271
|
+
for match in _PATH_TOKEN.finditer(text):
|
|
272
|
+
candidates.append((match.group(1) or match.group(2) or "").strip().rstrip(".,:;"))
|
|
273
|
+
candidates.extend(match.group(1) for match in _PLAIN_PATH.finditer(text))
|
|
274
|
+
return list(dict.fromkeys(candidates))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolved(path: str, root: pathlib.Path) -> str:
|
|
278
|
+
p = pathlib.Path(path).expanduser()
|
|
279
|
+
try:
|
|
280
|
+
return os.path.normcase(str((p if p.is_absolute() else root / p).resolve()))
|
|
281
|
+
except OSError:
|
|
282
|
+
return os.path.normcase(str(p if p.is_absolute() else root / p))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _written_this_turn(path: str, ledger: Ledger, root: pathlib.Path) -> bool:
|
|
286
|
+
path = re.sub(r"#L?\d+(-L?\d+)?$|:\d+(-\d+)?$", "", path)
|
|
287
|
+
target = _resolved(path, root)
|
|
288
|
+
# "Files created in C:\site" names the folder: a write to any file inside it counts.
|
|
289
|
+
inside = target.rstrip("\\/") + os.sep
|
|
290
|
+
|
|
291
|
+
def hit(written: str) -> bool:
|
|
292
|
+
w = _resolved(written, root)
|
|
293
|
+
return w == target or w.startswith(inside)
|
|
294
|
+
|
|
295
|
+
for step in ledger.steps:
|
|
296
|
+
if not step.ok or step.name not in _CHANGE_TOOLS:
|
|
297
|
+
continue
|
|
298
|
+
if step.name != "apply_patch":
|
|
299
|
+
if hit(str(step.args.get("path", ""))):
|
|
300
|
+
return True
|
|
301
|
+
continue
|
|
302
|
+
patch_paths = re.findall(r"^\*\*\* (?:Update|Add|Delete) File: (.+)$",
|
|
303
|
+
str(step.args.get("patch", "")), re.M)
|
|
304
|
+
if any(hit(item.strip()) for item in patch_paths):
|
|
305
|
+
return True
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def missing_paths(answer: str, ledger: Ledger, root: pathlib.Path) -> list[str]:
|
|
310
|
+
touched = ledger.touched()
|
|
311
|
+
out = []
|
|
312
|
+
for tok in _path_candidates(answer):
|
|
313
|
+
tok = re.sub(r"#L?\d+(-L?\d+)?$|:\d+(-\d+)?$", "", tok) # file.py#L12 / file.py:12
|
|
314
|
+
if not tok or "://" in tok or tok.startswith(("-", "$", "@", "<", "*")) or "*" in tok:
|
|
315
|
+
continue
|
|
316
|
+
if not _EXT.search(tok) or "(" in tok or "=" in tok:
|
|
317
|
+
continue
|
|
318
|
+
p = pathlib.Path(tok).expanduser()
|
|
319
|
+
p = p if p.is_absolute() else root / p
|
|
320
|
+
try:
|
|
321
|
+
if p.exists():
|
|
322
|
+
continue
|
|
323
|
+
except OSError:
|
|
324
|
+
continue
|
|
325
|
+
if tok.lower() in touched or any(tok.lower() in t for t in touched if len(t) < 400):
|
|
326
|
+
continue # a tool talked about it (deleted it, moved it, it was in an error) — not invented
|
|
327
|
+
if "/" not in tok and "\\" not in tok and _cheap(root) and next(root.rglob(tok), None):
|
|
328
|
+
continue # bare file name that exists somewhere in the project
|
|
329
|
+
out.append(tok)
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def missing_symbols(answer: str, root: pathlib.Path, ledger: Ledger | None = None) -> list[str]:
|
|
334
|
+
candidates = set()
|
|
335
|
+
for sent in _sentences(answer):
|
|
336
|
+
if _SYMBOL_CONTEXT.search(sent):
|
|
337
|
+
candidates.update(_SYMBOL.findall(sent))
|
|
338
|
+
if not candidates:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
evidenced = set()
|
|
342
|
+
if ledger:
|
|
343
|
+
for step in ledger.steps:
|
|
344
|
+
if not step.ok:
|
|
345
|
+
continue
|
|
346
|
+
evidence = step.out + "\n" + "\n".join(
|
|
347
|
+
value for value in step.args.values() if isinstance(value, str))
|
|
348
|
+
evidenced.update(name for name in candidates
|
|
349
|
+
if re.search(r"\b" + re.escape(name) + r"\b", evidence))
|
|
350
|
+
candidates -= evidenced
|
|
351
|
+
if not candidates:
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
definitions = re.compile(
|
|
355
|
+
r"\b(?:async\s+def|def|class|function|fn|func|interface|type|const|let|var)\s+("
|
|
356
|
+
+ "|".join(re.escape(name) for name in sorted(candidates, key=len, reverse=True))
|
|
357
|
+
+ r")\b")
|
|
358
|
+
found = set()
|
|
359
|
+
scanned = 0
|
|
360
|
+
bytes_scanned = 0
|
|
361
|
+
try:
|
|
362
|
+
for current, dirs, names in os.walk(root):
|
|
363
|
+
dirs[:] = [name for name in dirs if name not in _SKIP_DIRS]
|
|
364
|
+
for name in names:
|
|
365
|
+
if pathlib.Path(name).suffix.lower() not in {
|
|
366
|
+
".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt",
|
|
367
|
+
".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".swift",
|
|
368
|
+
}:
|
|
369
|
+
continue
|
|
370
|
+
scanned += 1
|
|
371
|
+
if scanned > 20000:
|
|
372
|
+
return []
|
|
373
|
+
file_path = pathlib.Path(current, name)
|
|
374
|
+
try:
|
|
375
|
+
size = file_path.stat().st_size
|
|
376
|
+
if size > 1_000_000:
|
|
377
|
+
continue
|
|
378
|
+
bytes_scanned += size
|
|
379
|
+
if bytes_scanned > 16_000_000:
|
|
380
|
+
return []
|
|
381
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
382
|
+
except OSError:
|
|
383
|
+
continue
|
|
384
|
+
found.update(definitions.findall(content))
|
|
385
|
+
if found == candidates:
|
|
386
|
+
return []
|
|
387
|
+
except OSError:
|
|
388
|
+
return []
|
|
389
|
+
return sorted(candidates - found)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _cheap(root: pathlib.Path) -> bool:
|
|
393
|
+
"""rglob is fine for a project folder, not for a home directory or drive root."""
|
|
394
|
+
try:
|
|
395
|
+
return root != pathlib.Path.home() and len(root.parts) > 1 and sum(1 for _ in root.iterdir()) < 400
|
|
396
|
+
except OSError:
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def bounce_message(problems: list[str]) -> str:
|
|
401
|
+
return ("[Forge check] Before this answer goes to the user: " + "; ".join(problems) + ". "
|
|
402
|
+
"Either do it now (run the tests or command, re-check the file), or rewrite the answer so it "
|
|
403
|
+
"says plainly what was not verified. Don't repeat a claim no tool has backed up.")
|
forgecli/hooks.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Opt-in local hooks from the user's trusted Forge configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from .config import HOME_DIR
|
|
10
|
+
|
|
11
|
+
HOOK_CONFIG = HOME_DIR / "hooks.json"
|
|
12
|
+
_ENV_KEYS = ("PATH", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "HOME", "USERPROFILE")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Hooks:
|
|
16
|
+
def __init__(self, cwd: pathlib.Path, ui):
|
|
17
|
+
self.cwd = cwd
|
|
18
|
+
self.ui = ui
|
|
19
|
+
|
|
20
|
+
def run(self, event: str, tool: str, args: dict) -> str:
|
|
21
|
+
if event not in ("pre_tool", "post_tool"):
|
|
22
|
+
return f"Error: unsupported hook event {event!r}."
|
|
23
|
+
if not HOOK_CONFIG.is_file():
|
|
24
|
+
return ""
|
|
25
|
+
try:
|
|
26
|
+
config = json.loads(HOOK_CONFIG.read_text(encoding="utf-8"))
|
|
27
|
+
except (OSError, ValueError) as exc:
|
|
28
|
+
return f"Error: cannot read {HOOK_CONFIG}: {exc}"
|
|
29
|
+
commands = config.get(event, [])
|
|
30
|
+
if not isinstance(commands, list):
|
|
31
|
+
return f"Error: {event} in {HOOK_CONFIG} must be a list of argument arrays."
|
|
32
|
+
env = {key: os.environ[key] for key in _ENV_KEYS if key in os.environ}
|
|
33
|
+
payload = json.dumps({"event": event, "tool": tool, "arguments": args,
|
|
34
|
+
"cwd": str(self.cwd)}, ensure_ascii=False)
|
|
35
|
+
for command in commands:
|
|
36
|
+
if (not isinstance(command, list) or not command or
|
|
37
|
+
any(not isinstance(part, str) or not part for part in command)):
|
|
38
|
+
return f"Error: invalid {event} hook in {HOOK_CONFIG}; expected a non-empty argument array."
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(command, input=payload, text=True, capture_output=True,
|
|
41
|
+
timeout=5, cwd=self.cwd, env=env, shell=False)
|
|
42
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
43
|
+
return f"Error: {event} hook {command[0]!r} failed: {exc}"
|
|
44
|
+
output = (result.stdout + result.stderr).strip()[:2_000]
|
|
45
|
+
if result.returncode:
|
|
46
|
+
message = f"{event} hook {command[0]!r} exited {result.returncode}"
|
|
47
|
+
if output:
|
|
48
|
+
message += f": {output}"
|
|
49
|
+
return message
|
|
50
|
+
return ""
|