Skip to content

Commit 4a4b278

Browse files
authored
Quality review + bugfix: v0.3 surface (#76)
A consolidation pass over the v0.3 surface (VS Code extension + leaderboard) before docs and the release cut. - Inline diagnostics now attach to the document holding the generated script (the applied target or a scratch doc), never the stale pre-apply buffer, and are not dropped on the no-editor path; a strict rejection opens the draft for review. - Diagnostic columns map gmat-script's 1-indexed UTF-8 byte offsets to VS Code's 0-indexed UTF-16 units. - Single-pass drafts are cancellable after the provider returns; a re-entry guard stops a cancelled-but-running draft from wedging the worker; `shutdown` stops the worker's read loop. - The held-out content firewall (assert_no_leak) is wired into the gated leaderboard build, scanning the serialized board for held-out request/intent text; the CLI reports a detected leak as exit 1. - recorded_usage no longer drops float token totals; the Space meta line no longer promises a held-out prompt count it never carries. Coverage 94% to 96.5% (cli.py 79% to 92%); mypy --strict and ruff clean. Closes #50
1 parent b0e0837 commit 4a4b278

9 files changed

Lines changed: 414 additions & 61 deletions

File tree

editors/vscode/src/extension.ts

Lines changed: 79 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ let output: vscode.OutputChannel;
2424
/** The progress reporter of an in-flight draft, so worker progress notifications can update it. */
2525
let activeProgress: vscode.Progress<{ message?: string }> | undefined;
2626

27+
/** Guards against re-entrant drafts: one generation at a time keeps the single worker from wedging
28+
* (a cancelled draft's provider call still occupies the worker) and the progress reporter unshared. */
29+
let draftInFlight = false;
30+
2731
/** A read-only scheme backing the apply-to-file diff preview. */
2832
const DRAFT_SCHEME = "gmat-copilot-draft";
2933
const draftContents = new Map<string, string>();
@@ -90,6 +94,12 @@ async function draftCommand(source: "input" | "selection"): Promise<void> {
9094
if (!activeWorker) {
9195
return;
9296
}
97+
if (draftInFlight) {
98+
vscode.window.showInformationMessage(
99+
"GMAT Copilot: a draft is already in progress — finish or dismiss it before starting another.",
100+
);
101+
return;
102+
}
93103
const editor = vscode.window.activeTextEditor;
94104
const intent = await resolveIntent(source, editor);
95105
if (!intent) {
@@ -108,36 +118,36 @@ async function draftCommand(source: "input" | "selection"): Promise<void> {
108118
dryRun: config.get<boolean>("dryRun", false),
109119
};
110120

111-
let result: DraftResult;
121+
draftInFlight = true;
112122
try {
113-
result = await vscode.window.withProgress(
114-
{ location: vscode.ProgressLocation.Notification, cancellable: true, title: "GMAT Copilot" },
115-
async (progress, token) => {
116-
activeProgress = progress;
117-
progress.report({ message: "Generating the mission script…" });
118-
try {
119-
return await activeWorker.draft(params, token);
120-
} finally {
121-
activeProgress = undefined;
122-
}
123-
},
124-
);
125-
} catch (err) {
126-
if (!isCancellation(err)) {
127-
showWorkerError(err);
123+
let result: DraftResult;
124+
try {
125+
result = await vscode.window.withProgress(
126+
{
127+
location: vscode.ProgressLocation.Notification,
128+
cancellable: true,
129+
title: "GMAT Copilot",
130+
},
131+
async (progress, token) => {
132+
activeProgress = progress;
133+
progress.report({ message: "Generating the mission script…" });
134+
try {
135+
return await activeWorker.draft(params, token);
136+
} finally {
137+
activeProgress = undefined;
138+
}
139+
},
140+
);
141+
} catch (err) {
142+
if (!isCancellation(err)) {
143+
showWorkerError(err);
144+
}
145+
return;
128146
}
129-
return;
130-
}
131-
132-
applyDiagnostics(editor?.document, result.diagnostics);
133-
if (result.rejected) {
134-
vscode.window.showWarningMessage(
135-
"GMAT Copilot: the draft did not validate clean and was not applied. See the Problems panel — " +
136-
"switch to permissive mode or refine the prompt.",
137-
);
138-
return;
147+
await presentResult(editor, result);
148+
} finally {
149+
draftInFlight = false;
139150
}
140-
await reviewAndApply(editor, result);
141151
}
142152

143153
async function resolveIntent(
@@ -162,20 +172,53 @@ async function resolveIntent(
162172
}
163173

164174
// -------------------------------------------------------------------- apply-to-current-file UX
165-
async function reviewAndApply(
175+
// The worker's diagnostics index into the *generated script*, so they are attached to a document
176+
// that actually holds that script — the applied target or a scratch document — never the pre-apply
177+
// active buffer (whose content is still the user's old text, so the squiggles would land on the
178+
// wrong lines), and never dropped when the draft opens in a fresh document.
179+
async function presentResult(
166180
editor: vscode.TextEditor | undefined,
167181
result: DraftResult,
168182
): Promise<void> {
169-
const script = result.script;
183+
if (result.rejected) {
184+
// Strict rejected the draft, so the user's file is left untouched. Open the best-effort draft in
185+
// a scratch document so its findings line up with the text they point at, in the Problems panel.
186+
const doc = await openDraft(result.script);
187+
applyDiagnostics(doc, result.diagnostics);
188+
vscode.window.showWarningMessage(rejectionMessage(result));
189+
return;
190+
}
170191
if (!editor) {
171-
const doc = await vscode.workspace.openTextDocument({ language: "gmat", content: script });
172-
await vscode.window.showTextDocument(doc);
192+
const doc = await openDraft(result.script);
193+
applyDiagnostics(doc, result.diagnostics);
173194
vscode.window.showInformationMessage(
174195
"GMAT Copilot: no active editor to apply to — opened the draft in a new document for review.",
175196
);
176197
return;
177198
}
199+
await reviewAndApply(editor, result);
200+
}
178201

202+
/** Open the generated script in a fresh editor document for review (no active file to apply to). */
203+
async function openDraft(script: string): Promise<vscode.TextDocument> {
204+
const doc = await vscode.workspace.openTextDocument({ language: "gmat", content: script });
205+
await vscode.window.showTextDocument(doc);
206+
return doc;
207+
}
208+
209+
/** Why a strict draft was rejected — a lint failure or, with the dry-run on, a runtime failure. */
210+
function rejectionMessage(result: DraftResult): string {
211+
const dryRun = result.dryRun;
212+
const why =
213+
dryRun && !dryRun.ok ? `did not pass the ${dryRun.tier}-tier dry-run` : "did not lint clean";
214+
return (
215+
`GMAT Copilot: the draft ${why} and was not applied. It is open for review with the findings ` +
216+
"in the Problems panel — switch to permissive mode or refine the prompt."
217+
);
218+
}
219+
220+
async function reviewAndApply(editor: vscode.TextEditor, result: DraftResult): Promise<void> {
221+
const script = result.script;
179222
const target = editor.document;
180223
const label = target.isUntitled ? "untitled" : path.basename(target.fileName);
181224
const draftUri = vscode.Uri.parse(`${DRAFT_SCHEME}:/${draftCounter++}/${label}`);
@@ -194,6 +237,7 @@ async function reviewAndApply(
194237
"Discard",
195238
);
196239
if (choice !== "Apply") {
240+
// Discarded: leave the user's file and any existing diagnostics on it untouched.
197241
return;
198242
}
199243
const edit = new vscode.WorkspaceEdit();
@@ -203,6 +247,10 @@ async function reviewAndApply(
203247
);
204248
edit.replace(target.uri, fullRange, script);
205249
const applied = await vscode.workspace.applyEdit(edit);
250+
if (applied) {
251+
// The target now holds the generated script, so the diagnostics line up with its content.
252+
applyDiagnostics(target, result.diagnostics);
253+
}
206254
vscode.window.showInformationMessage(
207255
applied
208256
? "GMAT Copilot: draft applied to the active file."

leaderboard/space/board.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,10 @@ function renderMeta(board) {
175175
["Judge model", board.judge_model],
176176
["Public set", `${publicSet.n_prompts ?? "?"} prompts · committed · reproduces offline`],
177177
[
178+
// The held-out size is deliberately not published — revealing it serves the overfitter, not
179+
// the reader, and the board never carries the count anyway (anti-overfitting hygiene, D16).
178180
"Held-out set",
179-
`${heldOutSet.n_prompts ?? "?"} prompts · never committed · ${
180-
heldOutSet.store || "private store, scored in gated CI"
181-
}`,
181+
`never committed · ${heldOutSet.store || "private store, scored in gated CI"}`,
182182
],
183183
])
184184
);

src/gmat_copilot/cli.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,13 +362,17 @@ def _leaderboard_build(args: argparse.Namespace) -> int:
362362
print(f"gmat-copilot leaderboard build: cannot read {args.config}: {exc}", file=sys.stderr)
363363
return 2
364364
held_out_root = Path(args.held_out) if args.held_out else None
365-
board, notes = build_from_config(
366-
config,
367-
root=Path(args.root),
368-
generated_at=args.generated_at or _now_utc(),
369-
tool_version=__version__,
370-
held_out_root=held_out_root,
371-
)
365+
try:
366+
board, notes = build_from_config(
367+
config,
368+
root=Path(args.root),
369+
generated_at=args.generated_at or _now_utc(),
370+
tool_version=__version__,
371+
held_out_root=held_out_root,
372+
)
373+
except LeaderboardError as exc:
374+
print(f"gmat-copilot leaderboard build: {exc}", file=sys.stderr)
375+
return 1
372376
for note in notes:
373377
print(f"gmat-copilot leaderboard: {note}", file=sys.stderr)
374378
text = dumps(board)

src/gmat_copilot/eval/leaderboard.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from ..providers import ProviderError
3333
from .judge import JUDGE_MODEL
3434
from .lift import LiftReport, run_recorded_lift
35+
from .prompts import load_prompts
3536
from .runner import EvalReport, run_recorded
3637

3738
__all__ = [
@@ -46,6 +47,7 @@
4647
"build_leaderboard",
4748
"bundle_sha16",
4849
"dumps",
50+
"held_out_secrets",
4951
"score_entry",
5052
"summarize",
5153
]
@@ -219,8 +221,10 @@ def recorded_usage(bundle: Path, *, model: str, n_votes: int) -> dict[str, int]:
219221
continue
220222
generations += 1
221223
for field, value in (entry.get("usage") or {}).items():
222-
if isinstance(value, int):
223-
totals[field] = totals.get(field, 0) + value
224+
# Token counts recorded as floats (e.g. 1234.0) must be summed, not silently dropped;
225+
# bool is an int subclass, so exclude it explicitly.
226+
if isinstance(value, (int, float)) and not isinstance(value, bool):
227+
totals[field] = totals.get(field, 0) + int(value)
224228
return {"generation_calls": generations, "judge_calls": generations * n_votes, **totals}
225229

226230

@@ -340,6 +344,27 @@ def assert_no_leak(serialized: str, secrets: Iterable[str]) -> None:
340344
raise LeaderboardError("a held-out gold leaked into the published board")
341345

342346

347+
def held_out_secrets(config: dict[str, Any], held_out_root: Path) -> list[str]:
348+
"""The held-out gold strings the published board must never contain — every held-out prompt's
349+
request and intent text, read from the private store under *held_out_root*.
350+
351+
Fed to :func:`assert_no_leak` so the firewall scans the bytes that ship, not just their key
352+
names (the structural :func:`assert_aggregate_only` check). Held-out bundles that have not been
353+
fetched are skipped, so this is a no-op offline and the realistic content scan only in gated CI.
354+
"""
355+
secrets: list[str] = []
356+
for seed in config.get("seeds", []):
357+
rel = seed.get("held_out_bundle")
358+
if not rel:
359+
continue
360+
prompts_path = held_out_root / rel / "prompts.json"
361+
if not prompts_path.exists():
362+
continue
363+
for prompt in load_prompts(prompts_path):
364+
secrets.extend((prompt.request, prompt.intent))
365+
return secrets
366+
367+
343368
def build_from_config(
344369
config: dict[str, Any],
345370
*,
@@ -396,4 +421,9 @@ def build_from_config(
396421
public_set=config.get("public_set", {}),
397422
held_out_set=config.get("held_out_set", {}),
398423
)
424+
# Defence in depth over the structural firewall: when the private held-out store is present (the
425+
# gated-CI build), scan the serialized board for any held-out request/intent text, so a gold
426+
# smuggled inside an allowed aggregate field is caught, not just an unexpected key name.
427+
if held_out_root is not None:
428+
assert_no_leak(dumps(board), held_out_secrets(config, held_out_root))
399429
return board, notes

src/gmat_copilot/generate.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,14 @@ def draft(
219219
:param gmat_root: GMAT install root forwarded to the dry-run (else ``GMAT_ROOT`` / discovery).
220220
:param dry_run_fn: a dynamic-tier dry-run to use in place of the real gmat-run subprocess (the
221221
eval's deterministic replay seam, decision D7); ``None`` uses the real dry-run.
222-
:param cancel: an optional predicate polled at each repair-attempt boundary (decision D15); when
223-
it returns true before an attempt begins, generation stops with :class:`DraftCancelled`. An
224-
in-flight provider call or dry-run runs to completion, so a single pass (``repair=0``) has
225-
no boundary to cancel at.
226-
:raises DraftCancelled: when *cancel* returns true before an attempt begins.
222+
:param cancel: an optional predicate polled before each attempt begins and again after the
223+
provider returns, before validation (decision D15); when it returns true generation stops
224+
with :class:`DraftCancelled`. The in-flight provider call (and a running dry-run) still
225+
completes — cancelling does not abort an HTTP request mid-flight — but a cancel observed
226+
after generation skips the potentially expensive dry-run, so even a single pass
227+
(``repair=0``) is cancellable between its generate and validate phases.
228+
:raises DraftCancelled: when *cancel* returns true before an attempt begins or after the
229+
provider returns.
227230
:raises DraftRejected: in strict mode, when the final draft still has blocking diagnostics.
228231
:raises ProviderError: when no model is resolved — either *model* is ``None`` with no provider
229232
to apply it to, or :func:`~gmat_copilot.providers.select` cannot resolve the selector.
@@ -254,6 +257,10 @@ def draft(
254257
last = provider.complete(
255258
prompt, model=model, temperature=temperature, max_tokens=max_tokens
256259
)
260+
if cancel is not None and cancel():
261+
raise DraftCancelled(
262+
f"generation cancelled after attempt {attempt + 1} of {repair + 1}"
263+
)
257264
script = _extract_script(last.text)
258265
verdict = evaluate(script, dry_run=dry_run, gmat_root=gmat_root, dry_run_fn=dry_run_fn)
259266
attempts.append(

src/gmat_copilot/worker.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -97,32 +97,45 @@ def write_message(writer: BinaryIO, message: Mapping[str, Any]) -> None:
9797

9898

9999
# ------------------------------------------------------------------- engine result -> VS Code shape
100-
def _line_end_char(text: str, line_1indexed: int) -> int:
101-
"""The 0-indexed end-of-line character, so a start-only diagnostic gets a visible squiggle.
100+
def _utf16_len(text: str) -> int:
101+
"""The length of *text* in UTF-16 code units — VS Code's position unit."""
102+
return len(text.encode("utf-16-le")) // 2
102103

103-
``LintDiagnostic`` keeps only the start position; widening to the line end gives the Problems
104-
panel a non-empty range to underline.
104+
105+
def _byte_col_to_utf16(line_text: str, column_1indexed: int) -> int:
106+
"""Convert a gmat-script 1-indexed UTF-8 *byte* column into a 0-indexed UTF-16 code-unit offset.
107+
108+
gmat-script positions are 1-indexed byte offsets within their line (its compiler convention),
109+
while VS Code ranges are 0-indexed UTF-16 code units. The two coincide only for ASCII — any
110+
multi-byte character earlier on the line (a unicode comment, a degree sign) would otherwise
111+
shift the squiggle. ``errors="ignore"`` keeps a malformed byte from crashing the surface; a byte
112+
column that splits a code point falls back to the nearest valid prefix.
105113
"""
106-
lines = text.splitlines()
107-
idx = line_1indexed - 1
108-
return len(lines[idx]) if 0 <= idx < len(lines) else 0
114+
byte_offset = max(column_1indexed - 1, 0)
115+
prefix = line_text.encode("utf-8")[:byte_offset].decode("utf-8", errors="ignore")
116+
return _utf16_len(prefix)
109117

110118

111119
def to_vscode_diagnostics(report: LintReport, source_text: str) -> list[dict[str, Any]]:
112120
"""Map a :class:`LintReport` into the VS Code ``Diagnostic`` JSON the Problems panel consumes.
113121
114-
gmat-script positions are 1-indexed; VS Code ranges are 0-indexed. ``source`` and ``code`` let
115-
the user filter gmat-copilot findings and click through to the rule.
122+
gmat-script positions are 1-indexed byte columns; VS Code ranges are 0-indexed UTF-16 units, so
123+
each column is converted (not merely decremented). ``LintDiagnostic`` keeps only the start
124+
position, so the range is widened to the line end to give the Problems panel a visible squiggle.
125+
``source`` and ``code`` let the user filter gmat-copilot findings and click through to the rule.
116126
"""
127+
lines = source_text.splitlines()
117128
out: list[dict[str, Any]] = []
118129
for d in report.diagnostics:
119130
line0 = max(d.line - 1, 0)
120-
char0 = max(d.column - 1, 0)
131+
line_text = lines[line0] if 0 <= line0 < len(lines) else ""
132+
end_char = _utf16_len(line_text)
133+
start_char = min(_byte_col_to_utf16(line_text, d.column), end_char)
121134
out.append(
122135
{
123136
"range": {
124-
"start": {"line": line0, "character": char0},
125-
"end": {"line": line0, "character": _line_end_char(source_text, d.line)},
137+
"start": {"line": line0, "character": start_char},
138+
"end": {"line": line0, "character": end_char},
126139
},
127140
"severity": _VSCODE_SEVERITY.get(d.severity, 3),
128141
"source": "gmat-copilot",
@@ -282,7 +295,11 @@ def _on_message(self, message: Mapping[str, Any]) -> None:
282295
if msg_id is None:
283296
return # an unrecognised notification — nothing to answer
284297
if method == "shutdown":
298+
# Acknowledge, then stop the read loop so the child exits on its own. This is a private
299+
# command protocol, not a real LSP server, so shutdown *is* the stop signal — the client
300+
# need not also send `exit`, and its `proc.kill()` is only a backstop.
285301
self._respond(msg_id, {"ok": True})
302+
self._running = False
286303
return
287304
event = threading.Event()
288305
with self._cancels_lock:

0 commit comments

Comments
 (0)