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.
@@ -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) :]
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+