cgh-codegen 0.1.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.
- cgh_codegen/__init__.py +24 -0
- cgh_codegen/backends.py +133 -0
- cgh_codegen/cli.py +295 -0
- cgh_codegen/flow.py +439 -0
- cgh_codegen/gate.py +73 -0
- cgh_codegen/generate.py +193 -0
- cgh_codegen/logview.py +42 -0
- cgh_codegen/mcp_tools.py +94 -0
- cgh_codegen/picker.py +238 -0
- cgh_codegen/py.typed +0 -0
- cgh_codegen-0.1.0.dist-info/METADATA +91 -0
- cgh_codegen-0.1.0.dist-info/RECORD +15 -0
- cgh_codegen-0.1.0.dist-info/WHEEL +5 -0
- cgh_codegen-0.1.0.dist-info/entry_points.txt +2 -0
- cgh_codegen-0.1.0.dist-info/top_level.txt +1 -0
cgh_codegen/generate.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
2
|
+
# __creation__ = 2026-09-15
|
|
3
|
+
# __author__ = "jndjama (Joy Ndjama)"
|
|
4
|
+
# __copyright__ = "Copyright 2026 ALTIKVA."
|
|
5
|
+
# __licence__ = "MIT"
|
|
6
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
7
|
+
# Description: The pure, I/O-free core of code generation. build_prompt turns
|
|
8
|
+
# a spec plus reference files into a (system, user) pair;
|
|
9
|
+
# extract_code pulls the code out of a model reply, dropping any
|
|
10
|
+
# surrounding prose so a cheap model's apology or half-answer
|
|
11
|
+
# never reaches disk; generate_code runs a Backend and returns
|
|
12
|
+
# the cleaned result. No file writes, no network, no gate here:
|
|
13
|
+
# those wrap this. A Backend is injectable, so FakeBackend is the
|
|
14
|
+
# whole test harness.
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
# Appended to both prompts. These are exactly the smells the in-flow ruff pass
|
|
23
|
+
# cannot clean on its own: ruff classes the fixes as "unsafe" (renaming an
|
|
24
|
+
# unused binding, rewriting % into an f-string), so `ruff check --fix` leaves
|
|
25
|
+
# them and the file would need a hand pass. Steering the cheap model away from
|
|
26
|
+
# them up front keeps generated code landing ruff-clean.
|
|
27
|
+
_CLEAN_CODE_RULE = (
|
|
28
|
+
" Write clean code that a linter accepts with no edits: no unused imports "
|
|
29
|
+
"or variables (use _ for an intentionally unused unpacked value), and use "
|
|
30
|
+
"f-strings, never %-formatting or .format(), for string interpolation."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# The system instruction. Two things it has to get right, both learned from
|
|
34
|
+
# dogfooding a cheap model: (1) the reference is a STYLE example, not content
|
|
35
|
+
# to reproduce, or the model echoes the whole reference back with the change
|
|
36
|
+
# spliced in; (2) the file must come inside a single fenced block, the frame
|
|
37
|
+
# that lets extract_code keep the code and drop any prose the model adds. A
|
|
38
|
+
# reply with no fence is treated as "nothing usable" rather than written out.
|
|
39
|
+
SYSTEM_PROMPT = (
|
|
40
|
+
"You write the complete contents of ONE new code file from a spec. "
|
|
41
|
+
"The reference files show the conventions, naming, structure and style to "
|
|
42
|
+
"follow. Imitate their style, but do NOT reproduce their content: output "
|
|
43
|
+
"only the new file the spec describes, never the reference itself. Return "
|
|
44
|
+
"it as a single fenced code block (```) and nothing outside the fence. If "
|
|
45
|
+
"you cannot produce the file, return an empty fenced block." + _CLEAN_CODE_RULE
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# The extend-mode twin. The instruction above is the wrong one when a file is
|
|
49
|
+
# being grown: told to write a complete file, a cheap model returns the whole
|
|
50
|
+
# thing rewritten, and appending that would duplicate everything already there.
|
|
51
|
+
EXTEND_SYSTEM_PROMPT = (
|
|
52
|
+
"You write ONE block of code to append to the end of an existing file. "
|
|
53
|
+
"You are shown that file: match its conventions, and do NOT repeat any of "
|
|
54
|
+
"it. Output only the new code, never the file's existing contents and "
|
|
55
|
+
"never the whole file. Return it as a single fenced code block (```) and "
|
|
56
|
+
"nothing outside the fence. If you cannot produce it, return an empty "
|
|
57
|
+
"fenced block." + _CLEAN_CODE_RULE
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_FENCE_RE = re.compile(r"```[^\n]*\n(.*?)```", re.DOTALL)
|
|
61
|
+
|
|
62
|
+
# The same match, but running to the LAST fence instead of the first. Needed
|
|
63
|
+
# when the generated file legitimately contains fences of its own.
|
|
64
|
+
_OUTER_FENCE_RE = re.compile(r"```[^\n]*\n(.*)```", re.DOTALL)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GenerationError(RuntimeError):
|
|
68
|
+
"""Raised when a backend returns nothing usable."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class GenResult:
|
|
73
|
+
"""A backend's reply after cleaning. ``cost`` is the backend's own
|
|
74
|
+
estimate in whatever unit it reports (0.0 for local backends)."""
|
|
75
|
+
|
|
76
|
+
code: str
|
|
77
|
+
cost: float = 0.0
|
|
78
|
+
backend: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@runtime_checkable
|
|
82
|
+
class Backend(Protocol):
|
|
83
|
+
"""A code-generating model. One method, so FakeBackend is trivial and
|
|
84
|
+
the real backends (agent CLI, local, cloud) drop in behind the same
|
|
85
|
+
seam later. ``is_local`` is True when generation never leaves the
|
|
86
|
+
machine (e.g. a local model); the flow skips the egress gate for those,
|
|
87
|
+
exactly like the rest of cgh."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
is_local: bool
|
|
91
|
+
|
|
92
|
+
def generate(self, system: str, user: str) -> tuple[str, float]:
|
|
93
|
+
"""Return (raw_text, cost). Must not raise for an empty reply,
|
|
94
|
+
return ("", cost) instead so the caller decides."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_prompt(
|
|
99
|
+
spec: str,
|
|
100
|
+
references: list[tuple[str, str]],
|
|
101
|
+
target: str | None = None,
|
|
102
|
+
prior: tuple[str, str] | None = None,
|
|
103
|
+
existing: str | None = None,
|
|
104
|
+
) -> tuple[str, str]:
|
|
105
|
+
"""Build the (system, user) prompt. ``references`` is a list of
|
|
106
|
+
(path, text), each wrapped as a style example the model imitates but does
|
|
107
|
+
not copy. Naming the ``target`` file up front keeps the model producing a
|
|
108
|
+
new file rather than echoing the reference; the spec leads so it is not
|
|
109
|
+
buried under a long reference. ``prior`` is (previous_code, check_output)
|
|
110
|
+
from a failed verify: it is fed back so the model fixes the specific
|
|
111
|
+
failure instead of guessing, which is what makes the self-correct loop
|
|
112
|
+
catch the subtleties a first pass misses. ``existing`` is the current text
|
|
113
|
+
of a file being extended rather than created: the model sees it and returns
|
|
114
|
+
only the block to append, never a rewrite, so nothing already in the file
|
|
115
|
+
can be lost to a careless regeneration."""
|
|
116
|
+
if existing is not None:
|
|
117
|
+
lead = (
|
|
118
|
+
f"Add to the existing file: {target}\n"
|
|
119
|
+
"Return ONLY the new code to append at the end of the file, not "
|
|
120
|
+
"the whole file. Match the conventions already in it and do not "
|
|
121
|
+
"repeat anything it already contains. Everything it needs must "
|
|
122
|
+
"already be imported there: if your addition would require a new "
|
|
123
|
+
"import, use a fully qualified reference instead.\n\n"
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
lead = f"Write the new file: {target}\n\n" if target else ""
|
|
127
|
+
blocks = [f"{lead}SPEC:\n{spec.strip()}\n"]
|
|
128
|
+
if existing is not None:
|
|
129
|
+
blocks.append(
|
|
130
|
+
f'<file_to_extend path="{target}">\n{existing}\n</file_to_extend>'
|
|
131
|
+
)
|
|
132
|
+
for path, text in references:
|
|
133
|
+
blocks.append(f'<style_example path="{path}">\n{text}\n</style_example>')
|
|
134
|
+
if prior is not None:
|
|
135
|
+
prev_code, check_output = prior
|
|
136
|
+
blocks.append(
|
|
137
|
+
"Your previous attempt did not pass its check. Return a corrected "
|
|
138
|
+
+ ("block to append" if existing is not None else "complete file")
|
|
139
|
+
+ " that fixes the failure below (keep everything that "
|
|
140
|
+
"was already correct).\n"
|
|
141
|
+
f"<previous_attempt>\n{prev_code}\n</previous_attempt>\n"
|
|
142
|
+
f"<check_failure>\n{check_output}\n</check_failure>"
|
|
143
|
+
)
|
|
144
|
+
system = EXTEND_SYSTEM_PROMPT if existing is not None else SYSTEM_PROMPT
|
|
145
|
+
return system, "\n\n".join(blocks)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def extract_code(reply: str) -> str:
|
|
149
|
+
"""Pull the code out of a model reply.
|
|
150
|
+
|
|
151
|
+
Only the first fenced code block counts: its contents are the file, and
|
|
152
|
+
any prose the model added around the fence is dropped. A reply with no
|
|
153
|
+
fence returns the empty string, which the caller reads as "nothing
|
|
154
|
+
usable" and refuses to write. This is deliberate: it is safer to refuse
|
|
155
|
+
an unframed reply than to write a cheap model's apology or half-answer
|
|
156
|
+
over a target file.
|
|
157
|
+
|
|
158
|
+
A generated file can legitimately contain fences of its own: a test that
|
|
159
|
+
builds a fenced model reply, a docs generator, anything that writes
|
|
160
|
+
Markdown. Stopping at the first closing fence cuts those off mid-file,
|
|
161
|
+
often mid-string, and the truncation is silent. So when the reply holds
|
|
162
|
+
more than the outer pair, run to the last fence instead of the first.
|
|
163
|
+
"""
|
|
164
|
+
pattern = _OUTER_FENCE_RE if reply.count("```") > 2 else _FENCE_RE
|
|
165
|
+
m = pattern.search(reply)
|
|
166
|
+
if m:
|
|
167
|
+
return m.group(1).strip("\n")
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def generate_code(
|
|
172
|
+
spec: str,
|
|
173
|
+
references: list[tuple[str, str]],
|
|
174
|
+
backend: Backend,
|
|
175
|
+
target: str | None = None,
|
|
176
|
+
prior: tuple[str, str] | None = None,
|
|
177
|
+
existing: str | None = None,
|
|
178
|
+
) -> GenResult:
|
|
179
|
+
"""Run one generation. Pure orchestration: build the prompt, call the
|
|
180
|
+
backend, clean the reply. No file is written and no egress gate is
|
|
181
|
+
consulted here; that is the caller's job. Raises GenerationError when the
|
|
182
|
+
backend returns nothing usable so an empty file is never produced.
|
|
183
|
+
``prior`` feeds a failed attempt's code + check output back for a retry.
|
|
184
|
+
``existing`` switches the prompt to extend that text instead of writing a
|
|
185
|
+
new file, in which case the result holds only the block to append."""
|
|
186
|
+
if not spec.strip():
|
|
187
|
+
raise GenerationError("empty spec")
|
|
188
|
+
system, user = build_prompt(spec, references, target, prior, existing)
|
|
189
|
+
raw, cost = backend.generate(system, user)
|
|
190
|
+
code = extract_code(raw)
|
|
191
|
+
if not code.strip():
|
|
192
|
+
raise GenerationError(f"backend {backend.name!r} returned no code")
|
|
193
|
+
return GenResult(code=code, cost=cost, backend=backend.name)
|
cgh_codegen/logview.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
2
|
+
# __creation__ = 2026-09-19
|
|
3
|
+
# __author__ = "jndjama (Joy Ndjama)"
|
|
4
|
+
# __copyright__ = "Copyright 2026 ALTIKVA."
|
|
5
|
+
# __licence__ = "MIT"
|
|
6
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
7
|
+
# Description: The human's model-free view of what codegen did. codegen already
|
|
8
|
+
# audits every generation to the shared activity log; codegen_activity
|
|
9
|
+
# reads that log back and keeps only codegen's own events, so the
|
|
10
|
+
# CLI can show the work without a single line entering an agent's
|
|
11
|
+
# context. This is the token-cheap surface: results are read from
|
|
12
|
+
# disk, never relayed through the expensive model.
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# The events codegen writes via flow._audit. Kept in one place so the log view
|
|
19
|
+
# and the writer cannot silently disagree about what counts as codegen activity.
|
|
20
|
+
CODEGEN_EVENTS = (
|
|
21
|
+
"codegen_generated",
|
|
22
|
+
"codegen_extended",
|
|
23
|
+
"codegen_egress_denied",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def codegen_activity(
|
|
28
|
+
repo_root: str | Path, limit: int = 20, scan: int = 1000
|
|
29
|
+
) -> list[tuple[float, str, str]]:
|
|
30
|
+
"""The recent codegen entries from the repo's activity log, oldest first,
|
|
31
|
+
as ``(ts, event, detail)`` tuples.
|
|
32
|
+
|
|
33
|
+
Reads at most the last ``scan`` activity entries (the log holds every
|
|
34
|
+
kind of event, not just codegen's), keeps the codegen ones, and returns
|
|
35
|
+
the last ``limit`` of those. An empty list means codegen has done nothing
|
|
36
|
+
here, or nothing within the scan window.
|
|
37
|
+
"""
|
|
38
|
+
from codegraph.plugin_api import activity_tail
|
|
39
|
+
|
|
40
|
+
entries = activity_tail(str(repo_root), max(1, scan))
|
|
41
|
+
hits = [e for e in entries if len(e) >= 2 and e[1] in CODEGEN_EVENTS]
|
|
42
|
+
return hits[-max(0, limit) :]
|
cgh_codegen/mcp_tools.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
2
|
+
# __creation__ = 2026-09-14
|
|
3
|
+
# __author__ = "jndjama (Joy Ndjama)"
|
|
4
|
+
# __copyright__ = "Copyright 2026 ALTIKVA."
|
|
5
|
+
# __licence__ = "MIT"
|
|
6
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
7
|
+
# Description: MCP tools codegen_pick(target, reference?) and
|
|
8
|
+
# codegen_write(spec, target, ...): pick the reference to mirror, and
|
|
9
|
+
# generate the file from a spec behind the egress gate. Both run
|
|
10
|
+
# inside the owner, so the graph read reuses the owner's
|
|
11
|
+
# connection.
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_mcp_registrar(config: dict):
|
|
19
|
+
def register_tools(mcp) -> None:
|
|
20
|
+
from codegraph.plugin_api import server_root
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def codegen_pick(target: str, reference: str = "") -> str:
|
|
24
|
+
"""
|
|
25
|
+
Pick the existing file to mirror when generating `target`
|
|
26
|
+
(a path you intend to write, e.g. tests/test_user_service.py).
|
|
27
|
+
Combines the code graph (a file defining a symbol related to
|
|
28
|
+
the target's name) with the target's own directory (a sibling
|
|
29
|
+
of the same kind), and returns the chosen reference, the reason,
|
|
30
|
+
and the runner-up candidates. Use it before writing boilerplate
|
|
31
|
+
so the new file matches an established pattern. Pass `reference`
|
|
32
|
+
to validate a specific file instead of picking one.
|
|
33
|
+
"""
|
|
34
|
+
from .picker import CodegenError, pick_reference
|
|
35
|
+
|
|
36
|
+
root = server_root()
|
|
37
|
+
if root is None:
|
|
38
|
+
return json.dumps({"error": "no repo root"})
|
|
39
|
+
try:
|
|
40
|
+
result = pick_reference(root, target, reference or None)
|
|
41
|
+
except CodegenError as exc:
|
|
42
|
+
return json.dumps({"error": str(exc)})
|
|
43
|
+
return json.dumps(result, indent=2)
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def codegen_write(
|
|
47
|
+
spec: str, target: str, reference: str = "", force: bool = False
|
|
48
|
+
) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Generate `target` from `spec` with a cheap model, mirroring an
|
|
51
|
+
existing file's conventions (picked from the graph, or the one
|
|
52
|
+
you pass as `reference`), and write it. Use this to hand off
|
|
53
|
+
predictable, pattern-following code (stubs, config, boilerplate)
|
|
54
|
+
so you spend no tokens producing it yourself. The reference is
|
|
55
|
+
run through the egress gate before it reaches a cloud model; a
|
|
56
|
+
confidential or PII-labeled reference is refused. An existing
|
|
57
|
+
target is not overwritten unless `force` is true.
|
|
58
|
+
|
|
59
|
+
The returned code is unverified: confirm it by running the
|
|
60
|
+
type-checker, linter, or tests, never by trusting that it is
|
|
61
|
+
correct because a later check was green.
|
|
62
|
+
"""
|
|
63
|
+
from .backends import resolve_backend
|
|
64
|
+
from .flow import run_generation
|
|
65
|
+
from .generate import GenerationError
|
|
66
|
+
from .picker import CodegenError
|
|
67
|
+
|
|
68
|
+
root = server_root()
|
|
69
|
+
if root is None:
|
|
70
|
+
return json.dumps({"error": "no repo root"})
|
|
71
|
+
backend = resolve_backend(config)
|
|
72
|
+
if backend is None:
|
|
73
|
+
return json.dumps(
|
|
74
|
+
{"error": "no backend configured ([plugin.codegen] command)"}
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
# The verify check comes from config, never the caller: an
|
|
78
|
+
# agent-supplied shell command would be an injection surface.
|
|
79
|
+
result = run_generation(
|
|
80
|
+
root,
|
|
81
|
+
spec,
|
|
82
|
+
target,
|
|
83
|
+
reference or None,
|
|
84
|
+
config=config,
|
|
85
|
+
backend=backend,
|
|
86
|
+
force=force,
|
|
87
|
+
verify=config.get("verify") or None,
|
|
88
|
+
max_attempts=max(1, int(config.get("max_attempts", 1))),
|
|
89
|
+
)
|
|
90
|
+
except (CodegenError, GenerationError) as exc:
|
|
91
|
+
return json.dumps({"error": str(exc)})
|
|
92
|
+
return json.dumps(result, indent=2)
|
|
93
|
+
|
|
94
|
+
return register_tools
|
cgh_codegen/picker.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
2
|
+
# __creation__ = 2026-09-14
|
|
3
|
+
# __author__ = "jndjama (Joy Ndjama)"
|
|
4
|
+
# __copyright__ = "Copyright 2026 ALTIKVA."
|
|
5
|
+
# __licence__ = "MIT"
|
|
6
|
+
# -#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#-#
|
|
7
|
+
# Description: Reference selection. Given the file to generate, find the best
|
|
8
|
+
# existing file to mirror. Two signals combined: the graph (a
|
|
9
|
+
# file defining a symbol related to the target's name, via the
|
|
10
|
+
# public find_symbol_files) and the filesystem (a sibling in the
|
|
11
|
+
# same directory of the same kind). Degrades to filesystem-only
|
|
12
|
+
# when the graph is unavailable. All candidate paths are confined
|
|
13
|
+
# to the repo root.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodegenError(RuntimeError):
|
|
23
|
+
"""Base error for the cgh-codegen plugin."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Name-stem prefixes/suffixes that carry no pattern signal on their own.
|
|
27
|
+
_NOISE_TOKENS = frozenset({"test", "tests", "spec", "specs", "impl", "base", "mod"})
|
|
28
|
+
|
|
29
|
+
# How long reference selection waits on the graph before giving up and using
|
|
30
|
+
# filesystem siblings only. Reference selection is normally sub-second; this is
|
|
31
|
+
# a generous ceiling that turns an indefinite hang behind a wedged owner into a
|
|
32
|
+
# bounded, fail-fast degradation.
|
|
33
|
+
_GRAPH_DEADLINE_S = 10.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class Candidate:
|
|
38
|
+
path: Path
|
|
39
|
+
score: int
|
|
40
|
+
reasons: list[str] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _confine(root: Path, candidate: str) -> Path | None:
|
|
44
|
+
"""Resolve ``candidate`` under ``root``; None if it escapes the root."""
|
|
45
|
+
p = Path(candidate)
|
|
46
|
+
resolved = (p if p.is_absolute() else root / p).resolve()
|
|
47
|
+
if resolved == root or root in resolved.parents:
|
|
48
|
+
return resolved
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split_words(stem: str) -> list[str]:
|
|
53
|
+
"""Break a file stem into lowercase words across snake_case and
|
|
54
|
+
camelCase/PascalCase, e.g. test_userService -> [test, user, service]."""
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
for chunk in re.split(r"[_\-.]+", stem):
|
|
57
|
+
parts.extend(re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+", chunk))
|
|
58
|
+
return [p.lower() for p in parts if p]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def name_tokens(stem: str) -> list[str]:
|
|
62
|
+
"""Case variants a symbol search should try for this stem. Drops the
|
|
63
|
+
noise words (test, spec, ...) so `test_user_service` searches for the
|
|
64
|
+
thing under test, then offers snake, Pascal and per-word variants since
|
|
65
|
+
the graph's substring match is case-sensitive."""
|
|
66
|
+
words = _split_words(stem)
|
|
67
|
+
meaningful = [w for w in words if w not in _NOISE_TOKENS] or words
|
|
68
|
+
tokens: list[str] = []
|
|
69
|
+
|
|
70
|
+
def add(t: str) -> None:
|
|
71
|
+
if t and t not in tokens:
|
|
72
|
+
tokens.append(t)
|
|
73
|
+
|
|
74
|
+
add("_".join(meaningful)) # snake_case
|
|
75
|
+
add("".join(w.capitalize() for w in meaningful)) # PascalCase
|
|
76
|
+
for w in meaningful:
|
|
77
|
+
add(w)
|
|
78
|
+
add(w.capitalize())
|
|
79
|
+
return tokens
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _graph_candidates_query(
|
|
83
|
+
root: Path, tokens: list[str]
|
|
84
|
+
) -> tuple[set[Path], bool | None]:
|
|
85
|
+
"""Files the graph says define a symbol matching any token. The bool is
|
|
86
|
+
graph availability: True if a query succeeded, False if the graph could
|
|
87
|
+
not be read at all, None if there was nothing to ask."""
|
|
88
|
+
from codegraph.plugin_api import find_symbol_files
|
|
89
|
+
|
|
90
|
+
files: set[Path] = set()
|
|
91
|
+
available: bool | None = None
|
|
92
|
+
for tok in tokens:
|
|
93
|
+
rows = find_symbol_files(str(root), tok)
|
|
94
|
+
if rows is None:
|
|
95
|
+
if available is None:
|
|
96
|
+
available = False
|
|
97
|
+
continue
|
|
98
|
+
available = True
|
|
99
|
+
for r in rows:
|
|
100
|
+
confined = _confine(root, r["file"])
|
|
101
|
+
if confined is not None:
|
|
102
|
+
files.add(confined)
|
|
103
|
+
return files, available
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _graph_candidates(
|
|
107
|
+
root: Path, tokens: list[str], timeout: float = _GRAPH_DEADLINE_S
|
|
108
|
+
) -> tuple[set[Path], bool | None]:
|
|
109
|
+
"""Bounded wrapper around the graph query. find_symbol_files talks to the
|
|
110
|
+
repo's owner, and a wedged owner (one whose reindex is stuck holding the
|
|
111
|
+
write lock) can block that read indefinitely, which is how a codegen call
|
|
112
|
+
hung for half an hour with no output. Run it under a deadline: if it does
|
|
113
|
+
not answer in time, treat the graph as unavailable and let selection fall
|
|
114
|
+
back to filesystem siblings, so codegen degrades instead of hanging."""
|
|
115
|
+
import threading
|
|
116
|
+
|
|
117
|
+
box: dict = {"result": (set(), None)}
|
|
118
|
+
|
|
119
|
+
def _run() -> None:
|
|
120
|
+
box["result"] = _graph_candidates_query(root, tokens)
|
|
121
|
+
|
|
122
|
+
worker = threading.Thread(target=_run, daemon=True)
|
|
123
|
+
worker.start()
|
|
124
|
+
worker.join(timeout)
|
|
125
|
+
if worker.is_alive():
|
|
126
|
+
# The graph read is stuck. Don't wait it out; the daemon thread unwinds
|
|
127
|
+
# on its own if the query ever returns. Graph unavailable, siblings only.
|
|
128
|
+
return set(), False
|
|
129
|
+
return box["result"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def pick_reference(
|
|
133
|
+
repo_root: str | Path,
|
|
134
|
+
target: str,
|
|
135
|
+
explicit_reference: str | None = None,
|
|
136
|
+
graph_timeout: float | None = None,
|
|
137
|
+
) -> dict:
|
|
138
|
+
"""Choose the file to mirror when generating ``target``.
|
|
139
|
+
|
|
140
|
+
Returns ``{reference, reason, candidates, graph_available}``.
|
|
141
|
+
``reference`` is a repo-relative path or None when nothing fits (the
|
|
142
|
+
caller then requires an explicit reference). An explicit reference is
|
|
143
|
+
validated and returned as-is.
|
|
144
|
+
"""
|
|
145
|
+
root = Path(repo_root).resolve()
|
|
146
|
+
|
|
147
|
+
if explicit_reference:
|
|
148
|
+
ref = _confine(root, explicit_reference)
|
|
149
|
+
if ref is None:
|
|
150
|
+
raise CodegenError(f"reference {explicit_reference!r} is outside the repo")
|
|
151
|
+
if not ref.is_file():
|
|
152
|
+
raise CodegenError(f"reference {explicit_reference!r} is not a file")
|
|
153
|
+
return {
|
|
154
|
+
"reference": _rel(root, ref),
|
|
155
|
+
"reason": "given by the caller",
|
|
156
|
+
"candidates": [],
|
|
157
|
+
"graph_available": None,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
tgt = _confine(root, target)
|
|
161
|
+
if tgt is None:
|
|
162
|
+
raise CodegenError(f"target {target!r} is outside the repo")
|
|
163
|
+
|
|
164
|
+
suffix = tgt.suffix
|
|
165
|
+
parent = tgt.parent
|
|
166
|
+
tokens = name_tokens(tgt.stem)
|
|
167
|
+
graph_files, graph_available = _graph_candidates(
|
|
168
|
+
root, tokens, graph_timeout if graph_timeout is not None else _GRAPH_DEADLINE_S
|
|
169
|
+
)
|
|
170
|
+
tgt_words = set(_split_words(tgt.stem))
|
|
171
|
+
|
|
172
|
+
# Candidate pool: existing files of the same kind, from the target's
|
|
173
|
+
# directory and from the graph hits. The target itself never counts.
|
|
174
|
+
pool: set[Path] = set()
|
|
175
|
+
if parent.exists():
|
|
176
|
+
pool.update(
|
|
177
|
+
p.resolve()
|
|
178
|
+
for p in parent.glob(f"*{suffix}")
|
|
179
|
+
if p.is_file() and p.resolve() != tgt
|
|
180
|
+
)
|
|
181
|
+
pool.update(f for f in graph_files if f.suffix == suffix and f != tgt)
|
|
182
|
+
|
|
183
|
+
# A file in the target's own directory is the same kind of thing in the
|
|
184
|
+
# same place (a new commands_*.py mirrors its commands_*.py neighbours,
|
|
185
|
+
# not a test that merely mentions the name), so the sibling weight is set
|
|
186
|
+
# above the graph-match weight: a same-directory sibling outranks a bare
|
|
187
|
+
# cross-directory symbol match, while a file that is BOTH still wins.
|
|
188
|
+
_GRAPH_WEIGHT = 3
|
|
189
|
+
_SIBLING_WEIGHT = 4
|
|
190
|
+
# Each shared name word weighs more than a bare graph symbol match, so
|
|
191
|
+
# among same-directory siblings the closest NAME wins: for a test the file
|
|
192
|
+
# name is the signal (test_dedup_family for a family-dedup test), and a
|
|
193
|
+
# graph hit on a differently-named sibling should not outrank it. Two
|
|
194
|
+
# shared words (2 x 2 = 4) clear the graph weight (3); one word does not.
|
|
195
|
+
_OVERLAP_WEIGHT = 2
|
|
196
|
+
scored: list[Candidate] = []
|
|
197
|
+
for path in pool:
|
|
198
|
+
c = Candidate(path=path, score=0)
|
|
199
|
+
if path in graph_files:
|
|
200
|
+
c.score += _GRAPH_WEIGHT
|
|
201
|
+
c.reasons.append("defines a matching symbol")
|
|
202
|
+
if path.parent == parent:
|
|
203
|
+
c.score += _SIBLING_WEIGHT
|
|
204
|
+
c.reasons.append("sibling in the same directory")
|
|
205
|
+
overlap = len(tgt_words & set(_split_words(path.stem)))
|
|
206
|
+
if overlap:
|
|
207
|
+
c.score += overlap * _OVERLAP_WEIGHT
|
|
208
|
+
c.reasons.append(f"shares {overlap} name word(s)")
|
|
209
|
+
if c.score:
|
|
210
|
+
scored.append(c)
|
|
211
|
+
|
|
212
|
+
scored.sort(key=lambda c: (c.score, str(c.path)), reverse=True)
|
|
213
|
+
|
|
214
|
+
if not scored:
|
|
215
|
+
return {
|
|
216
|
+
"reference": None,
|
|
217
|
+
"reason": (
|
|
218
|
+
"no analogue found in the graph or the target directory; "
|
|
219
|
+
"pass an explicit reference"
|
|
220
|
+
),
|
|
221
|
+
"candidates": [],
|
|
222
|
+
"graph_available": graph_available,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
best = scored[0]
|
|
226
|
+
return {
|
|
227
|
+
"reference": _rel(root, best.path),
|
|
228
|
+
"reason": ", ".join(best.reasons),
|
|
229
|
+
"candidates": [_rel(root, c.path) for c in scored[:5]],
|
|
230
|
+
"graph_available": graph_available,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _rel(root: Path, path: Path) -> str:
|
|
235
|
+
try:
|
|
236
|
+
return str(path.relative_to(root))
|
|
237
|
+
except ValueError:
|
|
238
|
+
return str(path)
|
cgh_codegen/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cgh-codegen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pattern-matched code generation for cgh: a cheap model writes boilerplate that mirrors a reference file cgh picks from the graph
|
|
5
|
+
Author-email: Joy Ndjama <joy.ndjama@altikva.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# cgh-codegen
|
|
11
|
+
|
|
12
|
+
A cgh plugin that delegates predictable, pattern-following code (tests,
|
|
13
|
+
stubs, config, boilerplate) to a cheap model so the primary model spends
|
|
14
|
+
no tokens producing it. Its distinguishing move: cgh picks the reference
|
|
15
|
+
file to mirror straight from the code graph, so you do not have to name it.
|
|
16
|
+
|
|
17
|
+
Installs through cgh's plugin entry point. Inert without cgh.
|
|
18
|
+
|
|
19
|
+
## Surfaces
|
|
20
|
+
|
|
21
|
+
### `cgh codegen pick`
|
|
22
|
+
|
|
23
|
+
Report the existing file a generator should mirror for a target, and why.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
cgh codegen pick --target tests/test_user_service.py
|
|
27
|
+
# reference: tests/test_order_service.py
|
|
28
|
+
# defines a matching symbol, sibling in the same directory
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The pick combines two signals: the graph (a file defining a symbol
|
|
32
|
+
related to the target's name) and the filesystem (a sibling of the same
|
|
33
|
+
kind in the target's directory). It degrades to a filesystem-only pick
|
|
34
|
+
when the graph is not readable (no index yet, or an owner holds the write
|
|
35
|
+
lock). Pass `--reference` to validate a specific file instead.
|
|
36
|
+
|
|
37
|
+
### `cgh codegen gen`
|
|
38
|
+
|
|
39
|
+
Generate a file from a spec, mirroring the reference, and write it.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
cgh codegen gen --spec "pytest tests for UserService: create, update, delete" \
|
|
43
|
+
--target tests/test_user_service.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The reference (picked, or forced with `--reference`) is run through the
|
|
47
|
+
egress gate before it reaches a cloud model: a confidential or PII-labeled
|
|
48
|
+
reference is refused. A local backend skips the gate. An existing target is
|
|
49
|
+
never overwritten without `--force`; `--stdout` prints instead of writing.
|
|
50
|
+
|
|
51
|
+
`--extend` grows a file that already exists instead of writing a new one:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
cgh codegen gen --extend --target tests/test_user_service.py \
|
|
55
|
+
--spec "add a test for the soft-delete path" \
|
|
56
|
+
--verify "pytest tests/test_user_service.py -q"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The file itself goes to the model as the thing to add to, and the model
|
|
60
|
+
returns only the block to append, so nothing already in the file passes
|
|
61
|
+
through the model's output and nothing can be dropped from it. The block
|
|
62
|
+
lands before a trailing `if __name__ == "__main__":` guard rather than after
|
|
63
|
+
it. With `--verify`, a check that never passes restores the original: a
|
|
64
|
+
damaged existing file is worse than no change, which is the opposite of the
|
|
65
|
+
tradeoff for a new file, where the failed draft is left for you to read.
|
|
66
|
+
Anything the addition needs must already be imported in the file.
|
|
67
|
+
|
|
68
|
+
Configure the backend in `.codegraph/config.toml`:
|
|
69
|
+
|
|
70
|
+
```toml
|
|
71
|
+
[plugin.codegen]
|
|
72
|
+
command = "claude -p" # any agent CLI, invoked with the prompt on stdin
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The generated code is a proposal. Verify it by running the type-checker,
|
|
76
|
+
linter, or tests, never by trusting that it is correct because a later check
|
|
77
|
+
was green. This matters most for generated tests: a green run of tests you
|
|
78
|
+
did not read proves nothing.
|
|
79
|
+
|
|
80
|
+
### `codegen_pick` and `codegen_write` (MCP tools)
|
|
81
|
+
|
|
82
|
+
`codegen_pick(target, reference?)` returns the selection as JSON.
|
|
83
|
+
`codegen_write(spec, target, reference?, force?)` generates and writes the
|
|
84
|
+
file, returning what it wrote, the reference used, the egress decision, and
|
|
85
|
+
the cost. Both run inside the owner, so the graph read reuses its
|
|
86
|
+
connection.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT. Plugins that interact with cgh only through the documented plugin
|
|
91
|
+
interfaces are not derivative works of cgh.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
cgh_codegen/__init__.py,sha256=gsXAnk3XIH5y-GdH6AsHqkii6X8cPMJWd5IJsMjeJsM,876
|
|
2
|
+
cgh_codegen/backends.py,sha256=aCNggT4xfRokhM3UAl7Xe-XzLan24KO1uUXVTGmyz4E,4943
|
|
3
|
+
cgh_codegen/cli.py,sha256=TvuYwe9GOuPac5vZ1TqlfM5uq4WNBDWg3ppNOllZUxA,10963
|
|
4
|
+
cgh_codegen/flow.py,sha256=JVZGpFqEZySxYO_PsXno1O-kiyjq-uyNXyzDtFhX_zI,17311
|
|
5
|
+
cgh_codegen/gate.py,sha256=WLEf15Giy2ZWDq_M42s9qbWsB5Tss9YpwNV9nEdZQsY,3170
|
|
6
|
+
cgh_codegen/generate.py,sha256=I-X_Lwwhw7RgH_gBryaissctV9m-5PRBiub3NijGo74,9169
|
|
7
|
+
cgh_codegen/logview.py,sha256=V9-eqrUp8yj-9ltYRgajqQ8y2RNYMwJutflwxempKyA,1826
|
|
8
|
+
cgh_codegen/mcp_tools.py,sha256=dI3AQSmrhuoscOq_UXXZCJhp_IdrU0vkVHhcmJoaodU,4187
|
|
9
|
+
cgh_codegen/picker.py,sha256=dhcuKPpsXSMNLPWbAFHxfiONQ5Rs0SiJrkkyAnkGMXQ,8953
|
|
10
|
+
cgh_codegen/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
cgh_codegen-0.1.0.dist-info/METADATA,sha256=_Z2A_c322WgSAJfFYMOUNWil9lMEFi36FEFanQYliKY,3607
|
|
12
|
+
cgh_codegen-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
13
|
+
cgh_codegen-0.1.0.dist-info/entry_points.txt,sha256=pFElgu1LhxBxkgjFepEH5mNCjIk8FIFTBzUMK65Ss9s,28
|
|
14
|
+
cgh_codegen-0.1.0.dist-info/top_level.txt,sha256=TXgsFyhkFZ5D1GWrgYK-oSGtK4vYFwfGxQTKbqwISi8,12
|
|
15
|
+
cgh_codegen-0.1.0.dist-info/RECORD,,
|